From e51d791a899ac854161ec8383327debd0639cb49 Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Thu, 13 Jul 2023 10:08:05 +0200 Subject: [PATCH] feat: login and registration with code --- courier/email_templates.go | 18 + courier/email_templates_test.go | 5 +- .../login_code/valid/email.body.gotmpl | 5 + .../valid/email.body.plaintext.gotmpl | 5 + .../login_code/valid/email.subject.gotmpl | 1 + .../registration_code/valid/email.body.gotmpl | 5 + .../valid/email.body.plaintext.gotmpl | 5 + .../valid/email.subject.gotmpl | 1 + courier/template/email/login_code_valid.go | 51 + .../template/email/login_code_valid_test.go | 30 + .../template/email/registration_code_valid.go | 51 + .../email/registration_code_valid_test.go | 30 + courier/template/template.go | 2 + driver/config/config.go | 56 +- driver/registry_default.go | 42 +- driver/registry_default_hooks.go | 7 + driver/registry_default_registration.go | 5 + embedx/config.schema.json | 511 ++------ embedx/identity_extension.schema.json | 18 +- identity/credentials_code.go | 12 +- identity/extension_credentials.go | 10 +- persistence/reference.go | 2 + .../0bc96cc9-dda4-4700-9e42-35731f2af91e.json | 3 +- .../1fb23c75-b809-42cc-8984-6ca2d0a1192f.json | 3 +- .../202c1981-1e25-47f0-8764-75ad506c2bec.json | 3 +- .../349c945a-60f8-436a-a301-7a42c92604f9.json | 3 +- .../38caf592-b042-4551-b92f-8d5223c2a4e2.json | 3 +- .../3a9ea34f-0f12-469b-9417-3ae5795a7baa.json | 3 +- .../43c99182-bb67-47e1-b564-bb23bd8d4393.json | 3 +- .../47edd3a8-0998-4779-9469-f4b8ee4430df.json | 3 +- .../56d94e8b-8a5d-4b7f-8a6e-3259d2b2903e.json | 3 +- .../6d387820-f2f4-4f9f-9980-a90d89e7811f.json | 3 +- .../916ded11-aa64-4a27-b06e-96e221a509d7.json | 3 +- .../99974ce6-388c-4669-a95a-7757ee724020.json | 3 +- .../b1fac7fb-d016-4a06-a7fe-e4eab2a0429f.json | 3 +- .../cccccccc-dda4-4700-9e42-35731f2af91e.json | 3 +- .../d6aa1f23-88c9-4b9b-a850-392f48c7f9e8.json | 3 +- .../f1f66a69-ce02-4a12-9591-9e02dda30a0d.json | 5 + .../05a7f09d-4ef3-41fb-958a-6ad74584b36a.json | 3 +- .../22d58184-b97d-44a5-bbaf-0aa8b4000d81.json | 3 +- .../2bf132e0-5d40-4df9-9a11-9106e5333735.json | 3 +- .../696e7022-c466-44f6-89c6-8cf93c06a62a.json | 3 +- .../69c80296-36cd-4afc-921a-15369cac5bf0.json | 14 + .../87fa3f43-5155-42b4-a1ad-174c2595fdaf.json | 3 +- .../8ef215a9-e8d5-43b3-9aa3-cb4333562e36.json | 3 +- .../8f32efdc-f6fc-4c27-a3c2-579d109eff60.json | 3 +- .../9edcf051-1cd0-44cc-bd2f-6ac21f0c24dd.json | 3 +- .../e2150cdc-23ac-4940-a240-6c79c27ab029.json | 3 +- .../ef18b06e-4700-4021-9949-ef783cd86be1.json | 3 +- .../ef18b06e-4700-4021-9949-ef783cd86be8.json | 3 +- .../f1b5ed18-113a-4a98-aae7-d4eba007199c.json | 3 +- persistence/sql/migratest/migration_test.go | 38 +- .../testdata/20220301102702_testdata.sql | 0 .../testdata/20230703143600_testdata.sql | 0 .../testdata/20230707133700_testdata.sql | 23 + .../testdata/20230707133701_testdata.sql | 22 + ...000_selfservice_login_flows_state.down.sql | 1 + ...00000_selfservice_login_flows_state.up.sql | 1 + ...fservice_registration_flows_state.down.sql | 1 + ...elfservice_registration_flows_state.up.sql | 1 + ...7133700000000_identity_login_code.down.sql | 4 + ...0000000_identity_login_code.mysql.down.sql | 4 + ...700000000_identity_login_code.mysql.up.sql | 34 + ...707133700000000_identity_login_code.up.sql | 33 + ...000001_identity_registration_code.down.sql | 4 + ..._identity_registration_code.mysql.down.sql | 4 + ...01_identity_registration_code.mysql.up.sql | 27 + ...00000001_identity_registration_code.up.sql | 27 + ...0_credential_types_code.cockroach.down.sql | 1 + ...000_credential_types_code.cockroach.up.sql | 1 + ...00000_credential_types_code.mysql.down.sql | 1 + ...2000000_credential_types_code.mysql.up.sql | 1 + ...00_credential_types_code.postgres.down.sql | 1 + ...0000_credential_types_code.postgres.up.sql | 1 + ...0000_credential_types_code.sqlite.down.sql | 1 + ...000000_credential_types_code.sqlite.up.sql | 1 + persistence/sql/persister_login.go | 125 ++ persistence/sql/persister_recovery.go | 7 +- persistence/sql/persister_registration.go | 129 +++ schema/extension.go | 3 + selfservice/flow/error_test.go | 23 + selfservice/flow/flow.go | 3 + selfservice/flow/login/flow.go | 23 + selfservice/flow/login/sort.go | 1 + selfservice/flow/login/state.go | 17 + selfservice/flow/name.go | 28 + selfservice/flow/recovery/flow.go | 16 +- selfservice/flow/recovery/flow_test.go | 2 +- selfservice/flow/recovery/handler.go | 1 - selfservice/flow/recovery/state.go | 33 +- selfservice/flow/registration/flow.go | 23 + selfservice/flow/registration/sort.go | 1 + selfservice/flow/registration/state.go | 15 + selfservice/flow/request.go | 36 +- selfservice/flow/request_test.go | 74 +- selfservice/flow/settings/flow.go | 16 +- selfservice/flow/settings/hook.go | 2 +- selfservice/flow/settings/state.go | 9 +- selfservice/flow/state.go | 44 + selfservice/flow/{recovery => }/state_test.go | 2 +- selfservice/flow/verification/error.go | 4 +- selfservice/flow/verification/flow.go | 16 +- selfservice/flow/verification/flow_test.go | 3 +- selfservice/flow/verification/state.go | 34 +- selfservice/flow/verification/state_test.go | 20 - selfservice/hook/code_address_verifier.go | 65 ++ selfservice/hook/verification.go | 8 +- selfservice/hook/verification_test.go | 5 +- .../strategy/code/.schema/login.schema.json | 5 +- .../code/.schema/registration.schema.json | 26 + ...erification_payloads_after_submission.json | 53 +- selfservice/strategy/code/code_login.go | 74 +- .../strategy/code/code_registration.go | 73 ++ selfservice/strategy/code/code_sender.go | 105 ++ selfservice/strategy/code/code_sender_test.go | 4 - selfservice/strategy/code/persistence.go | 21 + selfservice/strategy/code/schema.go | 3 + selfservice/strategy/code/strategy.go | 257 ++++- selfservice/strategy/code/strategy_login.go | 141 ++- .../strategy/code/strategy_login_test.go | 129 +++ .../strategy/code/strategy_recovery.go | 52 +- .../strategy/code/strategy_registration.go | 260 +++++ .../code/strategy_registration_test.go | 294 +++++ .../strategy/code/strategy_verification.go | 45 +- .../code/strategy_verification_test.go | 37 +- .../code/stub/code.identity.schema.json | 26 + selfservice/strategy/link/strategy.go | 22 +- .../strategy/link/strategy_recovery.go | 20 +- .../strategy/link/strategy_recovery_test.go | 41 +- .../strategy/link/strategy_verification.go | 19 +- .../link/strategy_verification_test.go | 38 +- selfservice/strategy/lookup/login.go | 2 +- selfservice/strategy/lookup/settings.go | 4 +- selfservice/strategy/lookup/settings_test.go | 18 +- selfservice/strategy/oidc/strategy.go | 3 + selfservice/strategy/oidc/strategy_login.go | 4 +- .../strategy/oidc/strategy_registration.go | 4 +- .../strategy/oidc/strategy_settings_test.go | 98 +- selfservice/strategy/password/login.go | 2 +- selfservice/strategy/password/registration.go | 2 +- selfservice/strategy/password/settings.go | 4 +- selfservice/strategy/password/strategy.go | 8 +- selfservice/strategy/profile/strategy.go | 4 +- selfservice/strategy/profile/strategy_test.go | 51 +- selfservice/strategy/totp/login.go | 2 +- selfservice/strategy/totp/settings.go | 6 +- selfservice/strategy/totp/settings_test.go | 18 +- ...ebauthn_login_is_invalid-type=browser.json | 3 +- ...if_webauthn_login_is_invalid-type=spa.json | 3 +- selfservice/strategy/webauthn/login.go | 2 +- selfservice/strategy/webauthn/registration.go | 2 +- selfservice/strategy/webauthn/settings.go | 4 +- .../strategy/webauthn/settings_test.go | 10 +- test/e2e/.go-version | 1 + .../code/registration/success.spec.ts | 71 ++ test/e2e/cypress/support/commands.ts | 86 +- test/e2e/cypress/support/config.d.ts | 1025 +++++++++-------- test/e2e/cypress/support/index.d.ts | 16 + test/e2e/profiles/code/.kratos.yml | 35 + .../profiles/code/identity.traits.schema.json | 35 + text/id.go | 22 +- text/message_login.go | 26 + text/message_node.go | 16 + text/message_registration.go | 26 + ui/container/container.go | 3 +- x/xsql/sql.go | 2 + 166 files changed, 3919 insertions(+), 1436 deletions(-) create mode 100644 courier/template/courier/builtin/templates/login_code/valid/email.body.gotmpl create mode 100644 courier/template/courier/builtin/templates/login_code/valid/email.body.plaintext.gotmpl create mode 100644 courier/template/courier/builtin/templates/login_code/valid/email.subject.gotmpl create mode 100644 courier/template/courier/builtin/templates/registration_code/valid/email.body.gotmpl create mode 100644 courier/template/courier/builtin/templates/registration_code/valid/email.body.plaintext.gotmpl create mode 100644 courier/template/courier/builtin/templates/registration_code/valid/email.subject.gotmpl create mode 100644 courier/template/email/login_code_valid.go create mode 100644 courier/template/email/login_code_valid_test.go create mode 100644 courier/template/email/registration_code_valid.go create mode 100644 courier/template/email/registration_code_valid_test.go create mode 100644 persistence/sql/migratest/fixtures/registration_code/f1f66a69-ce02-4a12-9591-9e02dda30a0d.json create mode 100644 persistence/sql/migratest/fixtures/registration_flow/69c80296-36cd-4afc-921a-15369cac5bf0.json create mode 100644 persistence/sql/migratest/testdata/20220301102702_testdata.sql create mode 100644 persistence/sql/migratest/testdata/20230703143600_testdata.sql create mode 100644 persistence/sql/migratest/testdata/20230707133700_testdata.sql create mode 100644 persistence/sql/migratest/testdata/20230707133701_testdata.sql create mode 100644 persistence/sql/migrations/sql/20230703143600000000_selfservice_login_flows_state.down.sql create mode 100644 persistence/sql/migrations/sql/20230703143600000000_selfservice_login_flows_state.up.sql create mode 100644 persistence/sql/migrations/sql/20230703143600000001_selfservice_registration_flows_state.down.sql create mode 100644 persistence/sql/migrations/sql/20230703143600000001_selfservice_registration_flows_state.up.sql create mode 100644 persistence/sql/migrations/sql/20230707133700000000_identity_login_code.down.sql create mode 100644 persistence/sql/migrations/sql/20230707133700000000_identity_login_code.mysql.down.sql create mode 100644 persistence/sql/migrations/sql/20230707133700000000_identity_login_code.mysql.up.sql create mode 100644 persistence/sql/migrations/sql/20230707133700000000_identity_login_code.up.sql create mode 100644 persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.down.sql create mode 100644 persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.mysql.down.sql create mode 100644 persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.mysql.up.sql create mode 100644 persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.up.sql create mode 100644 persistence/sql/migrations/sql/20230712173852000000_credential_types_code.cockroach.down.sql create mode 100644 persistence/sql/migrations/sql/20230712173852000000_credential_types_code.cockroach.up.sql create mode 100644 persistence/sql/migrations/sql/20230712173852000000_credential_types_code.mysql.down.sql create mode 100644 persistence/sql/migrations/sql/20230712173852000000_credential_types_code.mysql.up.sql create mode 100644 persistence/sql/migrations/sql/20230712173852000000_credential_types_code.postgres.down.sql create mode 100644 persistence/sql/migrations/sql/20230712173852000000_credential_types_code.postgres.up.sql create mode 100644 persistence/sql/migrations/sql/20230712173852000000_credential_types_code.sqlite.down.sql create mode 100644 persistence/sql/migrations/sql/20230712173852000000_credential_types_code.sqlite.up.sql create mode 100644 selfservice/flow/login/state.go create mode 100644 selfservice/flow/name.go create mode 100644 selfservice/flow/registration/state.go create mode 100644 selfservice/flow/state.go rename selfservice/flow/{recovery => }/state_test.go (97%) delete mode 100644 selfservice/flow/verification/state_test.go create mode 100644 selfservice/hook/code_address_verifier.go create mode 100644 selfservice/strategy/code/.schema/registration.schema.json create mode 100644 selfservice/strategy/code/code_registration.go create mode 100644 selfservice/strategy/code/strategy_login_test.go create mode 100644 selfservice/strategy/code/strategy_registration.go create mode 100644 selfservice/strategy/code/strategy_registration_test.go create mode 100644 selfservice/strategy/code/stub/code.identity.schema.json create mode 100644 test/e2e/.go-version create mode 100644 test/e2e/cypress/integration/profiles/code/registration/success.spec.ts create mode 100644 test/e2e/profiles/code/.kratos.yml create mode 100644 test/e2e/profiles/code/identity.traits.schema.json diff --git a/courier/email_templates.go b/courier/email_templates.go index 8d5d51f0ceaa..d2bae0a197e4 100644 --- a/courier/email_templates.go +++ b/courier/email_templates.go @@ -40,6 +40,8 @@ const ( TypeVerificationCodeValid TemplateType = "verification_code_valid" TypeOTP TemplateType = "otp" TypeTestStub TemplateType = "stub" + TypeLoginCodeValid TemplateType = "login_code_valid" + TypeRegistrationCodeValid TemplateType = "registration_code_valid" ) func GetEmailTemplateType(t EmailTemplate) (TemplateType, error) { @@ -60,6 +62,10 @@ func GetEmailTemplateType(t EmailTemplate) (TemplateType, error) { return TypeVerificationCodeInvalid, nil case *email.VerificationCodeValid: return TypeVerificationCodeValid, nil + case *email.LoginCodeValid: + return TypeLoginCodeValid, nil + case *email.RegistrationCodeValid: + return TypeRegistrationCodeValid, nil case *email.TestStub: return TypeTestStub, nil default: @@ -123,6 +129,18 @@ func NewEmailTemplateFromMessage(d template.Dependencies, msg Message) (EmailTem return nil, err } return email.NewTestStub(d, &t), nil + case TypeLoginCodeValid: + var t email.LoginCodeValidModel + if err := json.Unmarshal(msg.TemplateData, &t); err != nil { + return nil, err + } + return email.NewLoginCodeValid(d, &t), nil + case TypeRegistrationCodeValid: + var t email.RegistrationCodeValidModel + if err := json.Unmarshal(msg.TemplateData, &t); err != nil { + return nil, err + } + return email.NewRegistrationCodeValid(d, &t), nil default: return nil, errors.Errorf("received unexpected message template type: %s", msg.TemplateType) } diff --git a/courier/email_templates_test.go b/courier/email_templates_test.go index 2e8f8520bb7f..40afb5dc6863 100644 --- a/courier/email_templates_test.go +++ b/courier/email_templates_test.go @@ -27,6 +27,8 @@ func TestGetTemplateType(t *testing.T) { courier.TypeVerificationCodeInvalid: &email.VerificationCodeInvalid{}, courier.TypeVerificationCodeValid: &email.VerificationCodeValid{}, courier.TypeTestStub: &email.TestStub{}, + courier.TypeLoginCodeValid: &email.LoginCodeValid{}, + courier.TypeRegistrationCodeValid: &email.RegistrationCodeValid{}, } { t.Run(fmt.Sprintf("case=%s", expectedType), func(t *testing.T) { actualType, err := courier.GetEmailTemplateType(tmpl) @@ -50,6 +52,8 @@ func TestNewEmailTemplateFromMessage(t *testing.T) { courier.TypeVerificationCodeInvalid: email.NewVerificationCodeInvalid(reg, &email.VerificationCodeInvalidModel{To: "baz"}), courier.TypeVerificationCodeValid: email.NewVerificationCodeValid(reg, &email.VerificationCodeValidModel{To: "faz", VerificationURL: "http://bar.foo", VerificationCode: "123456678"}), courier.TypeTestStub: email.NewTestStub(reg, &email.TestStubModel{To: "far", Subject: "test subject", Body: "test body"}), + courier.TypeLoginCodeValid: email.NewLoginCodeValid(reg, &email.LoginCodeValidModel{To: "far", LoginCode: "123456"}), + courier.TypeRegistrationCodeValid: email.NewRegistrationCodeValid(reg, &email.RegistrationCodeValidModel{To: "far", RegistrationCode: "123456"}), } { t.Run(fmt.Sprintf("case=%s", tmplType), func(t *testing.T) { tmplData, err := json.Marshal(expectedTmpl) @@ -84,7 +88,6 @@ func TestNewEmailTemplateFromMessage(t *testing.T) { actualBodyPlaintext, err := actualTmpl.EmailBodyPlaintext(ctx) require.NoError(t, err) require.Equal(t, expectedBodyPlaintext, actualBodyPlaintext) - }) } } diff --git a/courier/template/courier/builtin/templates/login_code/valid/email.body.gotmpl b/courier/template/courier/builtin/templates/login_code/valid/email.body.gotmpl new file mode 100644 index 000000000000..505684b9849b --- /dev/null +++ b/courier/template/courier/builtin/templates/login_code/valid/email.body.gotmpl @@ -0,0 +1,5 @@ +Hi, + +please login to your account by entering the following code: + +{{ .LoginCode }} diff --git a/courier/template/courier/builtin/templates/login_code/valid/email.body.plaintext.gotmpl b/courier/template/courier/builtin/templates/login_code/valid/email.body.plaintext.gotmpl new file mode 100644 index 000000000000..505684b9849b --- /dev/null +++ b/courier/template/courier/builtin/templates/login_code/valid/email.body.plaintext.gotmpl @@ -0,0 +1,5 @@ +Hi, + +please login to your account by entering the following code: + +{{ .LoginCode }} diff --git a/courier/template/courier/builtin/templates/login_code/valid/email.subject.gotmpl b/courier/template/courier/builtin/templates/login_code/valid/email.subject.gotmpl new file mode 100644 index 000000000000..19d7bfd57d49 --- /dev/null +++ b/courier/template/courier/builtin/templates/login_code/valid/email.subject.gotmpl @@ -0,0 +1 @@ +Login to your account diff --git a/courier/template/courier/builtin/templates/registration_code/valid/email.body.gotmpl b/courier/template/courier/builtin/templates/registration_code/valid/email.body.gotmpl new file mode 100644 index 000000000000..6b9c31799995 --- /dev/null +++ b/courier/template/courier/builtin/templates/registration_code/valid/email.body.gotmpl @@ -0,0 +1,5 @@ +Hi, + +please complete your account registration by entering the following code: + +{{ .RegistrationCode }} diff --git a/courier/template/courier/builtin/templates/registration_code/valid/email.body.plaintext.gotmpl b/courier/template/courier/builtin/templates/registration_code/valid/email.body.plaintext.gotmpl new file mode 100644 index 000000000000..6b9c31799995 --- /dev/null +++ b/courier/template/courier/builtin/templates/registration_code/valid/email.body.plaintext.gotmpl @@ -0,0 +1,5 @@ +Hi, + +please complete your account registration by entering the following code: + +{{ .RegistrationCode }} diff --git a/courier/template/courier/builtin/templates/registration_code/valid/email.subject.gotmpl b/courier/template/courier/builtin/templates/registration_code/valid/email.subject.gotmpl new file mode 100644 index 000000000000..0f36292619ef --- /dev/null +++ b/courier/template/courier/builtin/templates/registration_code/valid/email.subject.gotmpl @@ -0,0 +1 @@ +Complete your account registration diff --git a/courier/template/email/login_code_valid.go b/courier/template/email/login_code_valid.go new file mode 100644 index 000000000000..2debc3a0cb7c --- /dev/null +++ b/courier/template/email/login_code_valid.go @@ -0,0 +1,51 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package email + +import ( + "context" + "encoding/json" + "os" + "strings" + + "github.com/ory/kratos/courier/template" +) + +type ( + LoginCodeValid struct { + deps template.Dependencies + model *LoginCodeValidModel + } + LoginCodeValidModel struct { + To string + LoginCode string + Identity map[string]interface{} + } +) + +func NewLoginCodeValid(d template.Dependencies, m *LoginCodeValidModel) *LoginCodeValid { + return &LoginCodeValid{deps: d, model: m} +} + +func (t *LoginCodeValid) EmailRecipient() (string, error) { + return t.model.To, nil +} + +func (t *LoginCodeValid) EmailSubject(ctx context.Context) (string, error) { + subject, err := template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "login_code/valid/email.subject.gotmpl", "login_code/valid/email.subject*", t.model, t.deps.CourierConfig().CourierTemplatesLoginCodeValid(ctx).Subject) + + return strings.TrimSpace(subject), err +} + +func (t *LoginCodeValid) EmailBody(ctx context.Context) (string, error) { + return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "login_code/valid/email.body.gotmpl", "login_code/valid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesLoginCodeValid(ctx).Body.HTML) +} + +func (t *LoginCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) { + return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "login_code/valid/email.body.plaintext.gotmpl", "login_code/valid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesLoginCodeValid(ctx).Body.PlainText) +} + +func (t *LoginCodeValid) MarshalJSON() ([]byte, error) { + return json.Marshal(t.model) +} diff --git a/courier/template/email/login_code_valid_test.go b/courier/template/email/login_code_valid_test.go new file mode 100644 index 000000000000..dca97defe08c --- /dev/null +++ b/courier/template/email/login_code_valid_test.go @@ -0,0 +1,30 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package email_test + +import ( + "context" + "testing" + + "github.com/ory/kratos/courier" + "github.com/ory/kratos/courier/template/email" + "github.com/ory/kratos/courier/template/testhelpers" + "github.com/ory/kratos/internal" +) + +func TestLoginCodeValid(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + t.Run("test=with courier templates directory", func(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + tpl := email.NewLoginCodeValid(reg, &email.LoginCodeValidModel{}) + + testhelpers.TestRendered(t, ctx, tpl) + }) + + t.Run("test=with remote resources", func(t *testing.T) { + testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/login_code/valid", courier.TypeLoginCodeValid) + }) +} diff --git a/courier/template/email/registration_code_valid.go b/courier/template/email/registration_code_valid.go new file mode 100644 index 000000000000..f7e39e334976 --- /dev/null +++ b/courier/template/email/registration_code_valid.go @@ -0,0 +1,51 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package email + +import ( + "context" + "encoding/json" + "os" + "strings" + + "github.com/ory/kratos/courier/template" +) + +type ( + RegistrationCodeValid struct { + deps template.Dependencies + model *RegistrationCodeValidModel + } + RegistrationCodeValidModel struct { + To string + Traits map[string]interface{} + RegistrationCode string + } +) + +func NewRegistrationCodeValid(d template.Dependencies, m *RegistrationCodeValidModel) *RegistrationCodeValid { + return &RegistrationCodeValid{deps: d, model: m} +} + +func (t *RegistrationCodeValid) EmailRecipient() (string, error) { + return t.model.To, nil +} + +func (t *RegistrationCodeValid) EmailSubject(ctx context.Context) (string, error) { + subject, err := template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "registration_code/valid/email.subject.gotmpl", "registration_code/valid/email.subject*", t.model, t.deps.CourierConfig().CourierTemplatesRegistrationCodeValid(ctx).Subject) + + return strings.TrimSpace(subject), err +} + +func (t *RegistrationCodeValid) EmailBody(ctx context.Context) (string, error) { + return template.LoadHTML(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "registration_code/valid/email.body.gotmpl", "registration_code/valid/email.body*", t.model, t.deps.CourierConfig().CourierTemplatesRegistrationCodeValid(ctx).Body.HTML) +} + +func (t *RegistrationCodeValid) EmailBodyPlaintext(ctx context.Context) (string, error) { + return template.LoadText(ctx, t.deps, os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)), "registration_code/valid/email.body.plaintext.gotmpl", "registration_code/valid/email.body.plaintext*", t.model, t.deps.CourierConfig().CourierTemplatesRegistrationCodeValid(ctx).Body.PlainText) +} + +func (t *RegistrationCodeValid) MarshalJSON() ([]byte, error) { + return json.Marshal(t.model) +} diff --git a/courier/template/email/registration_code_valid_test.go b/courier/template/email/registration_code_valid_test.go new file mode 100644 index 000000000000..be4cfe8059ea --- /dev/null +++ b/courier/template/email/registration_code_valid_test.go @@ -0,0 +1,30 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package email_test + +import ( + "context" + "testing" + + "github.com/ory/kratos/courier" + "github.com/ory/kratos/courier/template/email" + "github.com/ory/kratos/courier/template/testhelpers" + "github.com/ory/kratos/internal" +) + +func TestRegistrationCodeValid(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + t.Run("test=with courier templates directory", func(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + tpl := email.NewRegistrationCodeValid(reg, &email.RegistrationCodeValidModel{}) + + testhelpers.TestRendered(t, ctx, tpl) + }) + + t.Run("test=with remote resources", func(t *testing.T) { + testhelpers.TestRemoteTemplates(t, "../courier/builtin/templates/registration_code/valid", courier.TypeRegistrationCodeValid) + }) +} diff --git a/courier/template/template.go b/courier/template/template.go index 3ee99428aa5b..483c40bd2f5e 100644 --- a/courier/template/template.go +++ b/courier/template/template.go @@ -19,6 +19,8 @@ type ( CourierTemplatesVerificationValid() *config.CourierEmailTemplate CourierTemplatesRecoveryInvalid() *config.CourierEmailTemplate CourierTemplatesRecoveryValid() *config.CourierEmailTemplate + CourierTemplatesLoginValid() *config.CourierEmailTemplate + CourierTemplatesRegistrationValid() *config.CourierEmailTemplate } Dependencies interface { diff --git a/driver/config/config.go b/driver/config/config.go index bfa08bae2a3f..6a085c517873 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -68,6 +68,8 @@ const ( ViperKeyCourierTemplatesVerificationCodeValidEmail = "courier.templates.verification_code.valid.email" ViperKeyCourierDeliveryStrategy = "courier.delivery_strategy" ViperKeyCourierHTTPRequestConfig = "courier.http.request_config" + ViperKeyCourierTemplatesLoginCodeValidEmail = "courier.templates.login_code.valid.email" + ViperKeyCourierTemplatesRegistrationCodeValidEmail = "courier.templates.registration_code.valid.email" ViperKeyCourierSMTPFrom = "courier.smtp.from_address" ViperKeyCourierSMTPFromName = "courier.smtp.from_name" ViperKeyCourierSMTPHeaders = "courier.smtp.headers" @@ -225,6 +227,11 @@ type ( Enabled bool `json:"enabled"` Config json.RawMessage `json:"config"` } + SelfServiceStrategyCode struct { + RegistrationEnabled bool `json:"registration_enabled"` + LoginEnabled bool `json:"login_enabled"` + *SelfServiceStrategy + } Schema struct { ID string `json:"id" koanf:"id"` URL string `json:"url" koanf:"url"` @@ -278,6 +285,8 @@ type ( CourierTemplatesRecoveryCodeValid(ctx context.Context) *CourierEmailTemplate CourierTemplatesVerificationCodeInvalid(ctx context.Context) *CourierEmailTemplate CourierTemplatesVerificationCodeValid(ctx context.Context) *CourierEmailTemplate + CourierTemplatesLoginCodeValid(ctx context.Context) *CourierEmailTemplate + CourierTemplatesRegistrationCodeValid(ctx context.Context) *CourierEmailTemplate CourierMessageRetries(ctx context.Context) int } ) @@ -723,7 +732,9 @@ func (p *Config) SelfServiceStrategy(ctx context.Context, strategy string) *Self config = c } - enabledKey := fmt.Sprintf("%s.%s.enabled", ViperKeySelfServiceStrategyConfig, strategy) + basePath := fmt.Sprintf("%s.%s", ViperKeySelfServiceStrategyConfig, strategy) + + enabledKey := fmt.Sprintf("%s.enabled", basePath) s := &SelfServiceStrategy{ Enabled: pp.Bool(enabledKey), Config: json.RawMessage(config), @@ -733,6 +744,7 @@ func (p *Config) SelfServiceStrategy(ctx context.Context, strategy string) *Self // we need to forcibly set these values here: if !pp.Exists(enabledKey) { switch strategy { + case "otp": case "password": fallthrough case "profile": @@ -749,6 +761,40 @@ func (p *Config) SelfServiceStrategy(ctx context.Context, strategy string) *Self return s } +func (p *Config) SelfServiceCodeStrategy(ctx context.Context) *SelfServiceStrategyCode { + pp := p.GetProvider(ctx) + + config := "{}" + out, err := pp.Marshal(kjson.Parser()) + if err != nil { + p.l.WithError(err).Warn("Unable to marshal self service strategy configuration.") + } else if c := gjson.GetBytes(out, + fmt.Sprintf("%s.%s.config", ViperKeySelfServiceStrategyConfig, "code")).Raw; len(c) > 0 { + config = c + } + + basePath := fmt.Sprintf("%s.%s", ViperKeySelfServiceStrategyConfig, "code") + enabledKey := fmt.Sprintf("%s.enabled", basePath) + registrationKey := fmt.Sprintf("%s.registration_enabled", basePath) + loginKey := fmt.Sprintf("%s.login_enabled", basePath) + + s := &SelfServiceStrategyCode{ + SelfServiceStrategy: &SelfServiceStrategy{ + Enabled: pp.Bool(enabledKey), + Config: json.RawMessage(config), + }, + RegistrationEnabled: pp.Bool(registrationKey), + LoginEnabled: pp.Bool(loginKey), + } + + if !pp.Exists(enabledKey) { + s.RegistrationEnabled = false + s.LoginEnabled = false + s.Enabled = true + } + return s +} + func (p *Config) SecretsDefault(ctx context.Context) [][]byte { pp := p.GetProvider(ctx) secrets := pp.Strings(ViperKeySecretsDefault) @@ -1090,6 +1136,14 @@ func (p *Config) CourierTemplatesVerificationCodeValid(ctx context.Context) *Cou return p.CourierTemplatesHelper(ctx, ViperKeyCourierTemplatesVerificationCodeValidEmail) } +func (p *Config) CourierTemplatesLoginCodeValid(ctx context.Context) *CourierEmailTemplate { + return p.CourierTemplatesHelper(ctx, ViperKeyCourierTemplatesLoginCodeValidEmail) +} + +func (p *Config) CourierTemplatesRegistrationCodeValid(ctx context.Context) *CourierEmailTemplate { + return p.CourierTemplatesHelper(ctx, ViperKeyCourierTemplatesRegistrationCodeValidEmail) +} + func (p *Config) CourierMessageRetries(ctx context.Context) int { return p.GetProvider(ctx).IntF(ViperKeyCourierMessageRetries, 5) } diff --git a/driver/registry_default.go b/driver/registry_default.go index f4e9ba3fb040..417d5a0ed18d 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -96,11 +96,12 @@ type RegistryDefault struct { persister persistence.Persister migrationStatus popx.MigrationStatuses - hookVerifier *hook.Verifier - hookSessionIssuer *hook.SessionIssuer - hookSessionDestroyer *hook.SessionDestroyer - hookAddressVerifier *hook.AddressVerifier - hookShowVerificationUI *hook.ShowVerificationUIHook + hookVerifier *hook.Verifier + hookSessionIssuer *hook.SessionIssuer + hookSessionDestroyer *hook.SessionDestroyer + hookAddressVerifier *hook.AddressVerifier + hookShowVerificationUI *hook.ShowVerificationUIHook + hookCodeAddressVerifier *hook.CodeAddressVerifier identityHandler *identity.Handler identityValidator *identity.Validator @@ -326,10 +327,28 @@ func (m *RegistryDefault) selfServiceStrategies() []interface{} { return m.selfserviceStrategies } +func (m *RegistryDefault) strategyRegistrationEnabled(ctx context.Context, id string) bool { + switch id { + case identity.CredentialsTypeCodeAuth.String(): + return m.Config().SelfServiceCodeStrategy(ctx).RegistrationEnabled + default: + return m.Config().SelfServiceStrategy(ctx, id).Enabled + } +} + +func (m *RegistryDefault) strategyLoginEnabled(ctx context.Context, id string) bool { + switch id { + case identity.CredentialsTypeCodeAuth.String(): + return m.Config().SelfServiceCodeStrategy(ctx).LoginEnabled + default: + return m.Config().SelfServiceStrategy(ctx, id).Enabled + } +} + func (m *RegistryDefault) RegistrationStrategies(ctx context.Context) (registrationStrategies registration.Strategies) { for _, strategy := range m.selfServiceStrategies() { if s, ok := strategy.(registration.Strategy); ok { - if m.Config().SelfServiceStrategy(ctx, string(s.ID())).Enabled { + if m.strategyRegistrationEnabled(ctx, s.ID().String()) { registrationStrategies = append(registrationStrategies, s) } } @@ -351,7 +370,7 @@ func (m *RegistryDefault) AllRegistrationStrategies() registration.Strategies { func (m *RegistryDefault) LoginStrategies(ctx context.Context) (loginStrategies login.Strategies) { for _, strategy := range m.selfServiceStrategies() { if s, ok := strategy.(login.Strategy); ok { - if m.Config().SelfServiceStrategy(ctx, string(s.ID())).Enabled { + if m.strategyLoginEnabled(ctx, s.ID().String()) { loginStrategies = append(loginStrategies, s) } } @@ -649,7 +668,6 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize m.persister = p.WithNetworkID(net.ID) return nil }, bc) - if err != nil { return err } @@ -733,6 +751,14 @@ func (m *RegistryDefault) VerificationCodePersister() code.VerificationCodePersi return m.Persister() } +func (m *RegistryDefault) RegistrationCodePersister() code.RegistrationCodePersister { + return m.Persister() +} + +func (m *RegistryDefault) LoginCodePersister() code.LoginCodePersister { + return m.Persister() +} + func (m *RegistryDefault) Persister() persistence.Persister { return m.persister } diff --git a/driver/registry_default_hooks.go b/driver/registry_default_hooks.go index 6efffc05a777..c3f809d2144e 100644 --- a/driver/registry_default_hooks.go +++ b/driver/registry_default_hooks.go @@ -15,6 +15,13 @@ func (m *RegistryDefault) HookVerifier() *hook.Verifier { return m.hookVerifier } +func (m *RegistryDefault) HookCodeAddressVerifier() *hook.CodeAddressVerifier { + if m.hookCodeAddressVerifier == nil { + m.hookCodeAddressVerifier = hook.NewCodeAddressVerifier(m) + } + return m.hookCodeAddressVerifier +} + func (m *RegistryDefault) HookSessionIssuer() *hook.SessionIssuer { if m.hookSessionIssuer == nil { m.hookSessionIssuer = hook.NewSessionIssuer(m) diff --git a/driver/registry_default_registration.go b/driver/registry_default_registration.go index 7f78517891f0..060afcbdf5c7 100644 --- a/driver/registry_default_registration.go +++ b/driver/registry_default_registration.go @@ -28,6 +28,11 @@ func (m *RegistryDefault) PostRegistrationPostPersistHooks(ctx context.Context, initialHookCount = 1 } + if credentialsType == identity.CredentialsTypeCodeAuth && m.Config().SelfServiceCodeStrategy(ctx).RegistrationEnabled { + b = append(b, m.HookCodeAddressVerifier()) + initialHookCount += 1 + } + for _, v := range m.getHooks(string(credentialsType), m.Config().SelfServiceFlowRegistrationAfterHooks(ctx, string(credentialsType))) { if hook, ok := v.(registration.PostHookPostPersistExecutor); ok { b = append(b, hook) diff --git a/embedx/config.schema.json b/embedx/config.schema.json index ece7f175b20b..69dc5ed1cf6e 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -43,10 +43,7 @@ "description": "Ory Kratos redirects to this URL per default on completion of self-service flows and other browser interaction. Read this [article for more information on browser redirects](https://www.ory.sh/kratos/docs/concepts/browser-redirect-flow-completion).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/dashboard", - "/dashboard" - ] + "examples": ["https://my-app.com/dashboard", "/dashboard"] }, "selfServiceSessionRevokerHook": { "type": "object", @@ -56,9 +53,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceSessionIssuerHook": { "type": "object", @@ -68,9 +63,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceRequireVerifiedAddressHook": { "type": "object", @@ -80,9 +73,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceShowVerificationUIHook": { "type": "object", @@ -92,9 +83,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "webHookAuthBasicAuthProperties": { "properties": { @@ -114,17 +103,11 @@ } }, "additionalProperties": false, - "required": [ - "user", - "password" - ] + "required": ["user", "password"] } }, "additionalProperties": false, - "required": [ - "type", - "config" - ] + "required": ["type", "config"] }, "httpRequestConfig": { "type": "object", @@ -132,9 +115,7 @@ "url": { "title": "HTTP address of API endpoint", "description": "This URL will be used to send the emails to.", - "examples": [ - "https://example.com/api/v1/email" - ], + "examples": ["https://example.com/api/v1/email"], "type": "string", "pattern": "^https?://" }, @@ -199,25 +180,15 @@ "in": { "type": "string", "description": "How the api key should be transferred", - "enum": [ - "header", - "cookie" - ] + "enum": ["header", "cookie"] } }, "additionalProperties": false, - "required": [ - "name", - "value", - "in" - ] + "required": ["name", "value", "in"] } }, "additionalProperties": false, - "required": [ - "type", - "config" - ] + "required": ["type", "config"] }, "selfServiceWebHook": { "type": "object", @@ -256,10 +227,7 @@ "const": true } }, - "required": [ - "ignore", - "parse" - ] + "required": ["ignore", "parse"] } }, "url": { @@ -320,46 +288,30 @@ "response": { "properties": { "ignore": { - "enum": [ - true - ] + "enum": [true] } }, - "required": [ - "ignore" - ] + "required": ["ignore"] } }, - "required": [ - "response" - ] + "required": ["response"] } }, { "properties": { "can_interrupt": { - "enum": [ - false - ] + "enum": [false] } }, - "require": [ - "can_interrupt" - ] + "require": ["can_interrupt"] } ], "additionalProperties": false, - "required": [ - "url", - "method" - ] + "required": ["url", "method"] } }, "additionalProperties": false, - "required": [ - "hook", - "config" - ] + "required": ["hook", "config"] }, "OIDCClaims": { "title": "OpenID Connect claims", @@ -392,9 +344,7 @@ "essential": true }, "acr": { - "values": [ - "urn:mace:incommon:iap:silver" - ] + "values": ["urn:mace:incommon:iap:silver"] } } } @@ -442,9 +392,7 @@ "properties": { "id": { "type": "string", - "examples": [ - "google" - ] + "examples": ["google"] }, "provider": { "title": "Provider", @@ -471,9 +419,7 @@ "linkedin", "lark" ], - "examples": [ - "google" - ] + "examples": ["google"] }, "label": { "title": "Optional string which will be used when generating labels for UI buttons.", @@ -488,23 +434,17 @@ "issuer_url": { "type": "string", "format": "uri", - "examples": [ - "https://accounts.google.com" - ] + "examples": ["https://accounts.google.com"] }, "auth_url": { "type": "string", "format": "uri", - "examples": [ - "https://accounts.google.com/o/oauth2/v2/auth" - ] + "examples": ["https://accounts.google.com/o/oauth2/v2/auth"] }, "token_url": { "type": "string", "format": "uri", - "examples": [ - "https://www.googleapis.com/oauth2/v4/token" - ] + "examples": ["https://www.googleapis.com/oauth2/v4/token"] }, "mapper_url": { "title": "Jsonnet Mapper URL", @@ -521,10 +461,7 @@ "type": "array", "items": { "type": "string", - "examples": [ - "offline_access", - "profile" - ] + "examples": ["offline_access", "profile"] } }, "microsoft_tenant": { @@ -543,30 +480,21 @@ "title": "Microsoft subject source", "description": "Controls which source the subject identifier is taken from by microsoft provider. If set to `userinfo` (the default) then the identifier is taken from the `sub` field of OIDC ID token or data received from `/userinfo` standard OIDC endpoint. If set to `me` then the `id` field of data structure received from `https://graph.microsoft.com/v1.0/me` is taken as an identifier.", "type": "string", - "enum": [ - "userinfo", - "me" - ], + "enum": ["userinfo", "me"], "default": "userinfo", - "examples": [ - "userinfo" - ] + "examples": ["userinfo"] }, "apple_team_id": { "title": "Apple Developer Team ID", "description": "Apple Developer Team ID needed for generating a JWT token for client secret", "type": "string", - "examples": [ - "KP76DQS54M" - ] + "examples": ["KP76DQS54M"] }, "apple_private_key_id": { "title": "Apple Private Key Identifier", "description": "Sign In with Apple Private Key Identifier needed for generating a JWT token for client secret", "type": "string", - "examples": [ - "UX56C66723" - ] + "examples": ["UX56C66723"] }, "apple_private_key": { "title": "Apple Private Key", @@ -581,12 +509,7 @@ } }, "additionalProperties": false, - "required": [ - "id", - "provider", - "client_id", - "mapper_url" - ], + "required": ["id", "provider", "client_id", "mapper_url"], "allOf": [ { "if": { @@ -595,23 +518,17 @@ "const": "microsoft" } }, - "required": [ - "provider" - ] + "required": ["provider"] }, "then": { - "required": [ - "microsoft_tenant" - ] + "required": ["microsoft_tenant"] }, "else": { "not": { "properties": { "microsoft_tenant": {} }, - "required": [ - "microsoft_tenant" - ] + "required": ["microsoft_tenant"] } } }, @@ -622,9 +539,7 @@ "const": "apple" } }, - "required": [ - "provider" - ] + "required": ["provider"] }, "then": { "not": { @@ -634,9 +549,7 @@ "minLength": 1 } }, - "required": [ - "client_secret" - ] + "required": ["client_secret"] }, "required": [ "apple_private_key_id", @@ -645,9 +558,7 @@ ] }, "else": { - "required": [ - "client_secret" - ], + "required": ["client_secret"], "allOf": [ { "not": { @@ -657,9 +568,7 @@ "minLength": 1 } }, - "required": [ - "apple_team_id" - ] + "required": ["apple_team_id"] } }, { @@ -670,9 +579,7 @@ "minLength": 1 } }, - "required": [ - "apple_private_key_id" - ] + "required": ["apple_private_key_id"] } }, { @@ -683,9 +590,7 @@ "minLength": 1 } }, - "required": [ - "apple_private_key" - ] + "required": ["apple_private_key"] } } ] @@ -826,10 +731,7 @@ "title": "Required Authenticator Assurance Level", "description": "Sets what Authenticator Assurance Level (used for 2FA) is required to access this feature. If set to `highest_available` then this endpoint requires the highest AAL the identity has set up. If set to `aal1` then the identity can access this feature without 2FA.", "type": "string", - "enum": [ - "aal1", - "highest_available" - ], + "enum": ["aal1", "highest_available"], "default": "highest_available" }, "selfServiceAfterSettings": { @@ -947,6 +849,9 @@ "oidc": { "$ref": "#/definitions/selfServiceAfterRegistrationMethod" }, + "code": { + "$ref": "#/definitions/selfServiceAfterRegistrationMethod" + }, "hooks": { "$ref": "#/definitions/selfServiceHooks" } @@ -983,9 +888,7 @@ "path": { "title": "Path to PEM-encoded Fle", "type": "string", - "examples": [ - "path/to/file.pem" - ] + "examples": ["path/to/file.pem"] }, "base64": { "title": "Base64 Encoded Inline", @@ -1033,9 +936,7 @@ "$ref": "#/definitions/emailCourierTemplate" } }, - "required": [ - "email" - ] + "required": ["email"] }, "valid": { "additionalProperties": false, @@ -1045,9 +946,7 @@ "$ref": "#/definitions/emailCourierTemplate" } }, - "required": [ - "email" - ] + "required": ["email"] } } }, @@ -1097,9 +996,7 @@ "selfservice": { "type": "object", "additionalProperties": false, - "required": [ - "default_browser_return_url" - ], + "required": ["default_browser_return_url"], "properties": { "default_browser_return_url": { "$ref": "#/definitions/defaultReturnTo" @@ -1133,30 +1030,20 @@ "description": "URL where the Settings UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/user/settings" - ], + "examples": ["https://my-app.com/user/settings"], "default": "https://www.ory.sh/kratos/docs/fallback/settings" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "privileged_session_max_age": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "required_aal": { "$ref": "#/definitions/featureRequiredAal" @@ -1199,20 +1086,14 @@ "description": "URL where the Registration UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/signup" - ], + "examples": ["https://my-app.com/signup"], "default": "https://www.ory.sh/kratos/docs/fallback/registration" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeRegistration" @@ -1231,20 +1112,14 @@ "description": "URL where the Login UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/login" - ], + "examples": ["https://my-app.com/login"], "default": "https://www.ory.sh/kratos/docs/fallback/login" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeLogin" @@ -1270,9 +1145,7 @@ "description": "URL where the Ory Verify UI is hosted. This is the page where users activate and / or verify their email or telephone number. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/verify" - ], + "examples": ["https://my-app.com/verify"], "default": "https://www.ory.sh/kratos/docs/fallback/verification" }, "after": { @@ -1284,11 +1157,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeVerification" @@ -1297,10 +1166,7 @@ "title": "Verification Strategy", "description": "The strategy to use for verification requests", "type": "string", - "enum": [ - "link", - "code" - ], + "enum": ["link", "code"], "default": "code" }, "notify_unknown_recipients": { @@ -1327,9 +1193,7 @@ "description": "URL where the Ory Recovery UI is hosted. This is the page where users request and complete account recovery. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/verify" - ], + "examples": ["https://my-app.com/verify"], "default": "https://www.ory.sh/kratos/docs/fallback/recovery" }, "after": { @@ -1341,11 +1205,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeRecovery" @@ -1354,10 +1214,7 @@ "title": "Recovery Strategy", "description": "The strategy to use for recovery requests", "type": "string", - "enum": [ - "link", - "code" - ], + "enum": ["link", "code"], "default": "code" }, "notify_unknown_recipients": { @@ -1377,9 +1234,7 @@ "description": "URL where the Ory Kratos Error UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/kratos-error" - ], + "examples": ["https://my-app.com/kratos-error"], "default": "https://www.ory.sh/kratos/docs/fallback/error" } } @@ -1418,20 +1273,14 @@ "base_url": { "title": "Override the base URL which should be used as the base for recovery and verification links.", "type": "string", - "examples": [ - "https://my-app.com" - ] + "examples": ["https://my-app.com"] }, "lifespan": { "title": "How long a link is valid for", "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] } } } @@ -1441,6 +1290,16 @@ "type": "object", "additionalProperties": false, "properties": { + "login_enabled": { + "type": "boolean", + "title": "Enables Login with Code Method", + "default": false + }, + "registration_enabled": { + "type": "boolean", + "title": "Enables Registration with Code Method", + "default": false + }, "enabled": { "type": "boolean", "title": "Enables Code Method", @@ -1456,11 +1315,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] } } } @@ -1578,44 +1433,33 @@ }, "rp": { "title": "Relying Party (RP) Config", - "required": [ - "id", - "display_name" - ], + "required": ["id", "display_name"], "properties": { "display_name": { "type": "string", "title": "Relying Party Display Name", "description": "An name to help the user identify this RP.", - "examples": [ - "Ory Foundation" - ] + "examples": ["Ory Foundation"] }, "id": { "type": "string", "title": "Relying Party Identifier", "description": "The id must be a subset of the domain currently in the browser.", - "examples": [ - "ory.sh" - ] + "examples": ["ory.sh"] }, "origin": { "type": "string", "title": "Relying Party Origin", "description": "An explicit RP origin. If left empty, this defaults to `id`.", "format": "uri", - "examples": [ - "https://www.ory.sh/login" - ] + "examples": ["https://www.ory.sh/login"] }, "icon": { "type": "string", "title": "Relying Party Icon", "description": "An icon to help the user identify this RP.", "format": "uri", - "examples": [ - "https://www.ory.sh/an-icon.png" - ] + "examples": ["https://www.ory.sh/an-icon.png"] } }, "type": "object" @@ -1630,14 +1474,10 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] }, "then": { - "required": [ - "config" - ] + "required": ["config"] } }, "oidc": { @@ -1660,9 +1500,7 @@ "title": "Base URL for OAuth2 Redirect URIs", "description": "Can be used to modify the base URL for OAuth2 Redirect URLs. If unset, the Public Base URL will be used.", "format": "uri", - "examples": [ - "https://auth.myexample.org/" - ] + "examples": ["https://auth.myexample.org/"] }, "providers": { "title": "OpenID Connect and OAuth2 Providers", @@ -1761,18 +1599,13 @@ "type": "string", "title": "Override message templates", "description": "You can override certain or all message templates by pointing this key to the path where the templates are located.", - "examples": [ - "/conf/courier-templates" - ] + "examples": ["/conf/courier-templates"] }, "message_retries": { "description": "Defines the maximum number of times the sending of a message is retried after it failed before it is marked as abandoned", "type": "integer", "default": 5, - "examples": [ - 10, - 60 - ] + "examples": [10, 60] }, "delivery_strategy": { "title": "Delivery Strategy", @@ -1835,9 +1668,7 @@ "title": "SMTP Sender Name", "description": "The recipient of an email will see this as the sender name.", "type": "string", - "examples": [ - "Bob" - ] + "examples": ["Bob"] }, "headers": { "title": "SMTP Headers", @@ -1861,9 +1692,7 @@ "default": "localhost" } }, - "required": [ - "connection_uri" - ], + "required": ["connection_uri"], "additionalProperties": false }, "sms": { @@ -1888,9 +1717,7 @@ "url": { "title": "HTTP address of API endpoint", "description": "This URL will be used to connect to the SMS provider.", - "examples": [ - "https://api.twillio.com/sms/send" - ], + "examples": ["https://api.twillio.com/sms/send"], "type": "string", "pattern": "^https?:\\/\\/.*" }, @@ -1932,19 +1759,14 @@ }, "additionalProperties": false }, - "required": [ - "url", - "method" - ], + "required": ["url", "method"], "additionalProperties": false } }, "additionalProperties": false } }, - "required": [ - "smtp" - ], + "required": ["smtp"], "additionalProperties": false }, "oauth2_provider": { @@ -1975,10 +1797,10 @@ ] }, "override_return_to": { - "title":"Persist OAuth2 request between flows", - "type":"boolean", - "default":false, - "description":"Override the return_to query parameter with the OAuth2 provider request URL when perfoming an OAuth2 login flow." + "title": "Persist OAuth2 request between flows", + "type": "boolean", + "default": false, + "description": "Override the return_to query parameter with the OAuth2 provider request URL when perfoming an OAuth2 login flow." } }, "additionalProperties": false @@ -2006,9 +1828,7 @@ "description": "The URL where the admin endpoint is exposed at.", "type": "string", "format": "uri", - "examples": [ - "https://kratos.private-network:4434/" - ] + "examples": ["https://kratos.private-network:4434/"] }, "host": { "title": "Admin Host", @@ -2022,9 +1842,7 @@ "type": "integer", "minimum": 1, "maximum": 65535, - "examples": [ - 4434 - ], + "examples": [4434], "default": 4434 }, "socket": { @@ -2083,9 +1901,7 @@ ] }, "uniqueItems": true, - "default": [ - "*" - ], + "default": ["*"], "examples": [ [ "https://example.com", @@ -2097,13 +1913,7 @@ "allowed_methods": { "type": "array", "description": "A list of HTTP methods the user agent is allowed to use with cross-domain requests.", - "default": [ - "POST", - "GET", - "PUT", - "PATCH", - "DELETE" - ], + "default": ["POST", "GET", "PUT", "PATCH", "DELETE"], "items": { "type": "string", "enum": [ @@ -2134,9 +1944,7 @@ "exposed_headers": { "type": "array", "description": "Sets which headers are safe to expose to the API of a CORS API specification.", - "default": [ - "Content-Type" - ], + "default": ["Content-Type"], "items": { "type": "string" } @@ -2179,9 +1987,7 @@ "type": "integer", "minimum": 1, "maximum": 65535, - "examples": [ - 4433 - ], + "examples": [4433], "default": 4433 }, "socket": { @@ -2231,10 +2037,7 @@ "format": { "description": "The log format can either be text or JSON.", "type": "string", - "enum": [ - "json", - "text" - ] + "enum": ["json", "text"] } }, "additionalProperties": false @@ -2275,9 +2078,7 @@ "id": { "title": "The schema's ID.", "type": "string", - "examples": [ - "employee" - ] + "examples": ["employee"] }, "url": { "type": "string", @@ -2291,16 +2092,11 @@ ] } }, - "required": [ - "id", - "url" - ] + "required": ["id", "url"] } } }, - "required": [ - "schemas" - ], + "required": ["schemas"], "additionalProperties": false }, "secrets": { @@ -2349,10 +2145,7 @@ "description": "One of the values: argon2, bcrypt.\nAny other hashes will be migrated to the set algorithm once an identity authenticates using their password.", "type": "string", "default": "bcrypt", - "enum": [ - "argon2", - "bcrypt" - ] + "enum": ["argon2", "bcrypt"] }, "argon2": { "title": "Configuration for the Argon2id hasher.", @@ -2408,9 +2201,7 @@ "title": "Configuration for the Bcrypt hasher. Minimum is 4 when --dev flag is used and 12 otherwise.", "type": "object", "additionalProperties": false, - "required": [ - "cost" - ], + "required": ["cost"], "properties": { "cost": { "type": "integer", @@ -2432,11 +2223,7 @@ "description": "One of the values: noop, aes, xchacha20-poly1305", "type": "string", "default": "noop", - "enum": [ - "noop", - "aes", - "xchacha20-poly1305" - ] + "enum": ["noop", "aes", "xchacha20-poly1305"] } } }, @@ -2460,11 +2247,7 @@ "title": "HTTP Cookie Same Site Configuration", "description": "Sets the session and CSRF cookie SameSite.", "type": "string", - "enum": [ - "Strict", - "Lax", - "None" - ], + "enum": ["Strict", "Lax", "None"], "default": "Lax" } }, @@ -2491,11 +2274,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "24h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "cookie": { "type": "object", @@ -2526,11 +2305,7 @@ "title": "Session Cookie SameSite Configuration", "description": "Sets the session cookie SameSite. Overrides `cookies.same_site`.", "type": "string", - "enum": [ - "Strict", - "Lax", - "None" - ] + "enum": ["Strict", "Lax", "None"] } }, "additionalProperties": false @@ -2541,11 +2316,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "24h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] } } }, @@ -2554,9 +2325,7 @@ "description": "SemVer according to https://semver.org/ prefixed with `v` as in our releases.", "type": "string", "pattern": "^(v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?)|$", - "examples": [ - "v0.5.0-alpha.1" - ] + "examples": ["v0.5.0-alpha.1"] }, "dev": { "type": "boolean" @@ -2580,9 +2349,7 @@ "type": "integer", "minimum": 0, "maximum": 65535, - "examples": [ - 4434 - ], + "examples": [4434], "default": 0 }, "config": { @@ -2651,14 +2418,10 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] } }, - "required": [ - "verification" - ] + "required": ["verification"] }, { "properties": { @@ -2668,31 +2431,21 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] } }, - "required": [ - "recovery" - ] + "required": ["recovery"] } ] } }, - "required": [ - "flows" - ] + "required": ["flows"] } }, - "required": [ - "selfservice" - ] + "required": ["selfservice"] }, "then": { - "required": [ - "courier" - ] + "required": ["courier"] } }, { @@ -2711,33 +2464,21 @@ ] } }, - "required": [ - "algorithm" - ] + "required": ["algorithm"] } }, - "required": [ - "ciphers" - ] + "required": ["ciphers"] }, "then": { - "required": [ - "secrets" - ], + "required": ["secrets"], "properties": { "secrets": { - "required": [ - "cipher" - ] + "required": ["cipher"] } } } } ], - "required": [ - "identity", - "dsn", - "selfservice" - ], + "required": ["identity", "dsn", "selfservice"], "additionalProperties": false -} \ No newline at end of file +} diff --git a/embedx/identity_extension.schema.json b/embedx/identity_extension.schema.json index ef402cdadcfb..0148204d07e8 100644 --- a/embedx/identity_extension.schema.json +++ b/embedx/identity_extension.schema.json @@ -38,6 +38,15 @@ "type": "boolean" } } + }, + "code": { + "type": "object", + "additionalProperties": false, + "properties": { + "identifier": { + "type": "boolean" + } + } } } }, @@ -47,10 +56,7 @@ "properties": { "via": { "type": "string", - "enum": [ - "email", - "phone" - ] + "enum": ["email", "phone"] } } }, @@ -60,9 +66,7 @@ "properties": { "via": { "type": "string", - "enum": [ - "email" - ] + "enum": ["email"] } } } diff --git a/identity/credentials_code.go b/identity/credentials_code.go index b66d0964bbd9..2f6a32861ce0 100644 --- a/identity/credentials_code.go +++ b/identity/credentials_code.go @@ -1,9 +1,19 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + package identity +import ( + "database/sql" +) + // CredentialsOTP represents an OTP code // // swagger:model identityCredentialsOTP -type CredentialsOTP struct { +type CredentialsCode struct { // CodeHMAC represents the HMACed value of the login/registration code CodeHMAC string `json:"code_hmac"` + + // UsedAt indicates whether and when a recovery code was used. + UsedAt sql.NullTime `json:"used_at,omitempty"` } diff --git a/identity/extension_credentials.go b/identity/extension_credentials.go index 95a1ee8d4c93..c7c228f408bd 100644 --- a/identity/extension_credentials.go +++ b/identity/extension_credentials.go @@ -43,7 +43,7 @@ func (r *SchemaExtensionCredentials) setIdentifier(ct CredentialsType, value int r.i.SetCredentials(ct, *cred) } -func (r *SchemaExtensionCredentials) Run(_ jsonschema.ValidationContext, s schema.ExtensionConfig, value interface{}) error { +func (r *SchemaExtensionCredentials) Run(ctx jsonschema.ValidationContext, s schema.ExtensionConfig, value interface{}) error { r.l.Lock() defer r.l.Unlock() @@ -55,6 +55,14 @@ func (r *SchemaExtensionCredentials) Run(_ jsonschema.ValidationContext, s schem r.setIdentifier(CredentialsTypeWebAuthn, value) } + if s.Credentials.Code.Identifier { + // only `email` is supported for the `code` method on an identifier + if !jsonschema.Formats["email"](value) { + return ctx.Error("format", "%q is not a valid %q", value, "email") + } + r.setIdentifier(CredentialsTypeCodeAuth, value) + } + return nil } diff --git a/persistence/reference.go b/persistence/reference.go index 215ceb4a7f3f..56a7ca1712df 100644 --- a/persistence/reference.go +++ b/persistence/reference.go @@ -51,6 +51,8 @@ type Persister interface { link.VerificationTokenPersister code.RecoveryCodePersister code.VerificationCodePersister + code.RegistrationCodePersister + code.LoginCodePersister CleanupDatabase(context.Context, time.Duration, time.Duration, int) error Close(context.Context) error diff --git a/persistence/sql/migratest/fixtures/login_flow/0bc96cc9-dda4-4700-9e42-35731f2af91e.json b/persistence/sql/migratest/fixtures/login_flow/0bc96cc9-dda4-4700-9e42-35731f2af91e.json index e48e54d97a6b..ce8841aa07ff 100644 --- a/persistence/sql/migratest/fixtures/login_flow/0bc96cc9-dda4-4700-9e42-35731f2af91e.json +++ b/persistence/sql/migratest/fixtures/login_flow/0bc96cc9-dda4-4700-9e42-35731f2af91e.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/1fb23c75-b809-42cc-8984-6ca2d0a1192f.json b/persistence/sql/migratest/fixtures/login_flow/1fb23c75-b809-42cc-8984-6ca2d0a1192f.json index 5f63a7ec006a..770f0b2e2c38 100644 --- a/persistence/sql/migratest/fixtures/login_flow/1fb23c75-b809-42cc-8984-6ca2d0a1192f.json +++ b/persistence/sql/migratest/fixtures/login_flow/1fb23c75-b809-42cc-8984-6ca2d0a1192f.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal2" + "requested_aal": "aal2", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/202c1981-1e25-47f0-8764-75ad506c2bec.json b/persistence/sql/migratest/fixtures/login_flow/202c1981-1e25-47f0-8764-75ad506c2bec.json index efbd0740cdfb..b6cd377d812f 100644 --- a/persistence/sql/migratest/fixtures/login_flow/202c1981-1e25-47f0-8764-75ad506c2bec.json +++ b/persistence/sql/migratest/fixtures/login_flow/202c1981-1e25-47f0-8764-75ad506c2bec.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/349c945a-60f8-436a-a301-7a42c92604f9.json b/persistence/sql/migratest/fixtures/login_flow/349c945a-60f8-436a-a301-7a42c92604f9.json index 7586d19409ab..effdc9f1f2a0 100644 --- a/persistence/sql/migratest/fixtures/login_flow/349c945a-60f8-436a-a301-7a42c92604f9.json +++ b/persistence/sql/migratest/fixtures/login_flow/349c945a-60f8-436a-a301-7a42c92604f9.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal2" + "requested_aal": "aal2", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/38caf592-b042-4551-b92f-8d5223c2a4e2.json b/persistence/sql/migratest/fixtures/login_flow/38caf592-b042-4551-b92f-8d5223c2a4e2.json index 084b36a0c0b9..6eac76a4e91b 100644 --- a/persistence/sql/migratest/fixtures/login_flow/38caf592-b042-4551-b92f-8d5223c2a4e2.json +++ b/persistence/sql/migratest/fixtures/login_flow/38caf592-b042-4551-b92f-8d5223c2a4e2.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal2" + "requested_aal": "aal2", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/3a9ea34f-0f12-469b-9417-3ae5795a7baa.json b/persistence/sql/migratest/fixtures/login_flow/3a9ea34f-0f12-469b-9417-3ae5795a7baa.json index 13dff119fce0..577b054917db 100644 --- a/persistence/sql/migratest/fixtures/login_flow/3a9ea34f-0f12-469b-9417-3ae5795a7baa.json +++ b/persistence/sql/migratest/fixtures/login_flow/3a9ea34f-0f12-469b-9417-3ae5795a7baa.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/43c99182-bb67-47e1-b564-bb23bd8d4393.json b/persistence/sql/migratest/fixtures/login_flow/43c99182-bb67-47e1-b564-bb23bd8d4393.json index 5f1529c393b3..6f0fae29f575 100644 --- a/persistence/sql/migratest/fixtures/login_flow/43c99182-bb67-47e1-b564-bb23bd8d4393.json +++ b/persistence/sql/migratest/fixtures/login_flow/43c99182-bb67-47e1-b564-bb23bd8d4393.json @@ -13,5 +13,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": true, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/47edd3a8-0998-4779-9469-f4b8ee4430df.json b/persistence/sql/migratest/fixtures/login_flow/47edd3a8-0998-4779-9469-f4b8ee4430df.json index fe46265a6d2e..64a415dfba4a 100644 --- a/persistence/sql/migratest/fixtures/login_flow/47edd3a8-0998-4779-9469-f4b8ee4430df.json +++ b/persistence/sql/migratest/fixtures/login_flow/47edd3a8-0998-4779-9469-f4b8ee4430df.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/56d94e8b-8a5d-4b7f-8a6e-3259d2b2903e.json b/persistence/sql/migratest/fixtures/login_flow/56d94e8b-8a5d-4b7f-8a6e-3259d2b2903e.json index 85156c189e4d..e2ccb8f7616d 100644 --- a/persistence/sql/migratest/fixtures/login_flow/56d94e8b-8a5d-4b7f-8a6e-3259d2b2903e.json +++ b/persistence/sql/migratest/fixtures/login_flow/56d94e8b-8a5d-4b7f-8a6e-3259d2b2903e.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/6d387820-f2f4-4f9f-9980-a90d89e7811f.json b/persistence/sql/migratest/fixtures/login_flow/6d387820-f2f4-4f9f-9980-a90d89e7811f.json index c38727386af7..863594687d00 100644 --- a/persistence/sql/migratest/fixtures/login_flow/6d387820-f2f4-4f9f-9980-a90d89e7811f.json +++ b/persistence/sql/migratest/fixtures/login_flow/6d387820-f2f4-4f9f-9980-a90d89e7811f.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/916ded11-aa64-4a27-b06e-96e221a509d7.json b/persistence/sql/migratest/fixtures/login_flow/916ded11-aa64-4a27-b06e-96e221a509d7.json index eb8ec21e0e31..138f4838c466 100644 --- a/persistence/sql/migratest/fixtures/login_flow/916ded11-aa64-4a27-b06e-96e221a509d7.json +++ b/persistence/sql/migratest/fixtures/login_flow/916ded11-aa64-4a27-b06e-96e221a509d7.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/99974ce6-388c-4669-a95a-7757ee724020.json b/persistence/sql/migratest/fixtures/login_flow/99974ce6-388c-4669-a95a-7757ee724020.json index 418e16ebe69b..41bc0e84748f 100644 --- a/persistence/sql/migratest/fixtures/login_flow/99974ce6-388c-4669-a95a-7757ee724020.json +++ b/persistence/sql/migratest/fixtures/login_flow/99974ce6-388c-4669-a95a-7757ee724020.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/b1fac7fb-d016-4a06-a7fe-e4eab2a0429f.json b/persistence/sql/migratest/fixtures/login_flow/b1fac7fb-d016-4a06-a7fe-e4eab2a0429f.json index 84eda2f96615..ae28f38c8fe4 100644 --- a/persistence/sql/migratest/fixtures/login_flow/b1fac7fb-d016-4a06-a7fe-e4eab2a0429f.json +++ b/persistence/sql/migratest/fixtures/login_flow/b1fac7fb-d016-4a06-a7fe-e4eab2a0429f.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/cccccccc-dda4-4700-9e42-35731f2af91e.json b/persistence/sql/migratest/fixtures/login_flow/cccccccc-dda4-4700-9e42-35731f2af91e.json index 438fb4005e14..e2d58f6dc1fe 100644 --- a/persistence/sql/migratest/fixtures/login_flow/cccccccc-dda4-4700-9e42-35731f2af91e.json +++ b/persistence/sql/migratest/fixtures/login_flow/cccccccc-dda4-4700-9e42-35731f2af91e.json @@ -13,5 +13,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/login_flow/d6aa1f23-88c9-4b9b-a850-392f48c7f9e8.json b/persistence/sql/migratest/fixtures/login_flow/d6aa1f23-88c9-4b9b-a850-392f48c7f9e8.json index 87ccb1d1dcd0..00a1a2d7c3e5 100644 --- a/persistence/sql/migratest/fixtures/login_flow/d6aa1f23-88c9-4b9b-a850-392f48c7f9e8.json +++ b/persistence/sql/migratest/fixtures/login_flow/d6aa1f23-88c9-4b9b-a850-392f48c7f9e8.json @@ -12,5 +12,6 @@ "created_at": "2013-10-07T08:23:19Z", "updated_at": "2013-10-07T08:23:19Z", "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_code/f1f66a69-ce02-4a12-9591-9e02dda30a0d.json b/persistence/sql/migratest/fixtures/registration_code/f1f66a69-ce02-4a12-9591-9e02dda30a0d.json new file mode 100644 index 000000000000..5e429ce3d9ca --- /dev/null +++ b/persistence/sql/migratest/fixtures/registration_code/f1f66a69-ce02-4a12-9591-9e02dda30a0d.json @@ -0,0 +1,5 @@ +{ + "id": "f1f66a69-ce02-4a12-9591-9e02dda30a0d", + "expires_at": "2022-08-18T08:28:18Z", + "issued_at": "2022-08-18T07:28:18Z" +} diff --git a/persistence/sql/migratest/fixtures/registration_flow/05a7f09d-4ef3-41fb-958a-6ad74584b36a.json b/persistence/sql/migratest/fixtures/registration_flow/05a7f09d-4ef3-41fb-958a-6ad74584b36a.json index 1e649d64ad51..ccfcf94814a5 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/05a7f09d-4ef3-41fb-958a-6ad74584b36a.json +++ b/persistence/sql/migratest/fixtures/registration_flow/05a7f09d-4ef3-41fb-958a-6ad74584b36a.json @@ -8,5 +8,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/22d58184-b97d-44a5-bbaf-0aa8b4000d81.json b/persistence/sql/migratest/fixtures/registration_flow/22d58184-b97d-44a5-bbaf-0aa8b4000d81.json index 7f90a694387d..5c110a3394f5 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/22d58184-b97d-44a5-bbaf-0aa8b4000d81.json +++ b/persistence/sql/migratest/fixtures/registration_flow/22d58184-b97d-44a5-bbaf-0aa8b4000d81.json @@ -8,5 +8,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/2bf132e0-5d40-4df9-9a11-9106e5333735.json b/persistence/sql/migratest/fixtures/registration_flow/2bf132e0-5d40-4df9-9a11-9106e5333735.json index dbc832d2aa71..8df52efff06b 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/2bf132e0-5d40-4df9-9a11-9106e5333735.json +++ b/persistence/sql/migratest/fixtures/registration_flow/2bf132e0-5d40-4df9-9a11-9106e5333735.json @@ -8,5 +8,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/696e7022-c466-44f6-89c6-8cf93c06a62a.json b/persistence/sql/migratest/fixtures/registration_flow/696e7022-c466-44f6-89c6-8cf93c06a62a.json index 6b627d7541f9..d58beb9edffc 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/696e7022-c466-44f6-89c6-8cf93c06a62a.json +++ b/persistence/sql/migratest/fixtures/registration_flow/696e7022-c466-44f6-89c6-8cf93c06a62a.json @@ -9,5 +9,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/69c80296-36cd-4afc-921a-15369cac5bf0.json b/persistence/sql/migratest/fixtures/registration_flow/69c80296-36cd-4afc-921a-15369cac5bf0.json new file mode 100644 index 000000000000..e5fcc4a278f1 --- /dev/null +++ b/persistence/sql/migratest/fixtures/registration_flow/69c80296-36cd-4afc-921a-15369cac5bf0.json @@ -0,0 +1,14 @@ +{ + "id": "69c80296-36cd-4afc-921a-15369cac5bf0", + "type": "browser", + "expires_at": "2013-10-07T08:23:19Z", + "issued_at": "2013-10-07T08:23:19Z", + "request_url": "http://kratos:4433/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge=", + "active": "password", + "ui": { + "action": "", + "method": "", + "nodes": null + }, + "state": "" +} diff --git a/persistence/sql/migratest/fixtures/registration_flow/87fa3f43-5155-42b4-a1ad-174c2595fdaf.json b/persistence/sql/migratest/fixtures/registration_flow/87fa3f43-5155-42b4-a1ad-174c2595fdaf.json index 6a1dcdac29dd..19104b6d9f26 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/87fa3f43-5155-42b4-a1ad-174c2595fdaf.json +++ b/persistence/sql/migratest/fixtures/registration_flow/87fa3f43-5155-42b4-a1ad-174c2595fdaf.json @@ -9,5 +9,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/8ef215a9-e8d5-43b3-9aa3-cb4333562e36.json b/persistence/sql/migratest/fixtures/registration_flow/8ef215a9-e8d5-43b3-9aa3-cb4333562e36.json index ed2e8512fde1..616af278cd82 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/8ef215a9-e8d5-43b3-9aa3-cb4333562e36.json +++ b/persistence/sql/migratest/fixtures/registration_flow/8ef215a9-e8d5-43b3-9aa3-cb4333562e36.json @@ -9,5 +9,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/8f32efdc-f6fc-4c27-a3c2-579d109eff60.json b/persistence/sql/migratest/fixtures/registration_flow/8f32efdc-f6fc-4c27-a3c2-579d109eff60.json index df3f9c392998..a1f323ba3c4d 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/8f32efdc-f6fc-4c27-a3c2-579d109eff60.json +++ b/persistence/sql/migratest/fixtures/registration_flow/8f32efdc-f6fc-4c27-a3c2-579d109eff60.json @@ -9,5 +9,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/9edcf051-1cd0-44cc-bd2f-6ac21f0c24dd.json b/persistence/sql/migratest/fixtures/registration_flow/9edcf051-1cd0-44cc-bd2f-6ac21f0c24dd.json index 2195263f1574..1e6cc2579af2 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/9edcf051-1cd0-44cc-bd2f-6ac21f0c24dd.json +++ b/persistence/sql/migratest/fixtures/registration_flow/9edcf051-1cd0-44cc-bd2f-6ac21f0c24dd.json @@ -9,5 +9,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/e2150cdc-23ac-4940-a240-6c79c27ab029.json b/persistence/sql/migratest/fixtures/registration_flow/e2150cdc-23ac-4940-a240-6c79c27ab029.json index 497f88de81b2..560741f9a18d 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/e2150cdc-23ac-4940-a240-6c79c27ab029.json +++ b/persistence/sql/migratest/fixtures/registration_flow/e2150cdc-23ac-4940-a240-6c79c27ab029.json @@ -9,5 +9,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/ef18b06e-4700-4021-9949-ef783cd86be1.json b/persistence/sql/migratest/fixtures/registration_flow/ef18b06e-4700-4021-9949-ef783cd86be1.json index 8947653d90e7..ce1272433edf 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/ef18b06e-4700-4021-9949-ef783cd86be1.json +++ b/persistence/sql/migratest/fixtures/registration_flow/ef18b06e-4700-4021-9949-ef783cd86be1.json @@ -9,5 +9,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/ef18b06e-4700-4021-9949-ef783cd86be8.json b/persistence/sql/migratest/fixtures/registration_flow/ef18b06e-4700-4021-9949-ef783cd86be8.json index 6763bf5c63f5..4d1d58bdaf51 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/ef18b06e-4700-4021-9949-ef783cd86be8.json +++ b/persistence/sql/migratest/fixtures/registration_flow/ef18b06e-4700-4021-9949-ef783cd86be8.json @@ -9,5 +9,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/fixtures/registration_flow/f1b5ed18-113a-4a98-aae7-d4eba007199c.json b/persistence/sql/migratest/fixtures/registration_flow/f1b5ed18-113a-4a98-aae7-d4eba007199c.json index d894073c5468..c7d1b8207a4e 100644 --- a/persistence/sql/migratest/fixtures/registration_flow/f1b5ed18-113a-4a98-aae7-d4eba007199c.json +++ b/persistence/sql/migratest/fixtures/registration_flow/f1b5ed18-113a-4a98-aae7-d4eba007199c.json @@ -9,5 +9,6 @@ "action": "", "method": "", "nodes": null - } + }, + "state": "" } diff --git a/persistence/sql/migratest/migration_test.go b/persistence/sql/migratest/migration_test.go index 36126f147935..798afd54dc79 100644 --- a/persistence/sql/migratest/migration_test.go +++ b/persistence/sql/migratest/migration_test.go @@ -73,7 +73,8 @@ func CompareWithFixture(t *testing.T, actual interface{}, prefix string, id stri func TestMigrations_SQLite(t *testing.T) { t.Parallel() sqlite, err := pop.NewConnection(&pop.ConnectionDetails{ - URL: "sqlite3://" + filepath.Join(os.TempDir(), x.NewUUID().String()) + ".sql?_fk=true"}) + URL: "sqlite3://" + filepath.Join(os.TempDir(), x.NewUUID().String()) + ".sql?_fk=true", + }) require.NoError(t, err) require.NoError(t, sqlite.Open()) @@ -105,7 +106,6 @@ func TestMigrations_Cockroach(t *testing.T) { } func testDatabase(t *testing.T, db string, c *pop.Connection) { - ctx := context.Background() l := logrusx.New("", "", logrusx.ForceLevel(logrus.ErrorLevel)) @@ -372,6 +372,40 @@ func testDatabase(t *testing.T, db string, c *pop.Connection) { migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "recovery_code"), found) }) + t.Run("case=registration_code", func(t *testing.T) { + wg.Add(1) + defer wg.Done() + t.Parallel() + + var ids []code.RegistrationCode + require.NoError(t, c.All(&ids)) + require.NotEmpty(t, ids) + + var found []string + for _, id := range ids { + found = append(found, id.ID.String()) + CompareWithFixture(t, id, "registration_code", id.ID.String()) + } + migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "registration_code"), found) + }) + + t.Run("case=login_code", func(t *testing.T) { + wg.Add(1) + defer wg.Done() + t.Parallel() + + var ids []code.LoginCode + require.NoError(t, c.All(&ids)) + require.NotEmpty(t, ids) + + var found []string + for _, id := range ids { + found = append(found, id.ID.String()) + CompareWithFixture(t, id, "login_code", id.ID.String()) + } + migratest.ContainsExpectedIds(t, filepath.Join("fixtures", "login_code"), found) + }) + t.Run("suite=constraints", func(t *testing.T) { // This is not really a parallel test, but we have to mark it parallel so the other tests run first. t.Parallel() diff --git a/persistence/sql/migratest/testdata/20220301102702_testdata.sql b/persistence/sql/migratest/testdata/20220301102702_testdata.sql new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/persistence/sql/migratest/testdata/20230703143600_testdata.sql b/persistence/sql/migratest/testdata/20230703143600_testdata.sql new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/persistence/sql/migratest/testdata/20230707133700_testdata.sql b/persistence/sql/migratest/testdata/20230707133700_testdata.sql new file mode 100644 index 000000000000..20d877f5be1e --- /dev/null +++ b/persistence/sql/migratest/testdata/20230707133700_testdata.sql @@ -0,0 +1,23 @@ +INSERT INTO selfservice_login_flows (id, nid, request_url, issued_at, expires_at, active_method, csrf_token, created_at, + updated_at, forced, type, ui, internal_context, oauth2_login_challenge_data) +VALUES ('00b1517f-2467-4aaf-b0a5-82b4a27dcaf5', + '0c175792-3aad-4795-ad03-972e8a88f94c', + 'http://kratos:4433/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login/self-service/browser/flows/login', + '2013-10-07 08:23:19', '2013-10-07 08:23:19', '', + 'fpeVSZ9ZH7YvUkhXsOVEIssxbfauh5lcoQSYxTcN0XkMneg1L42h+HtvisjlNjBF4ElcD2jApCHoJYq2u9sVWg==', + '2013-10-07 08:23:19', '2013-10-07 08:23:19', false, 'api', '{}', '{"foo":"bar"}', 'challenge data'); + + +INSERT INTO identity_login_codes (id, code, used_at, expires_at, issued_at, selfservice_login_flow_id, + identity_verifiable_address_id, created_at, updated_at, nid) +VALUES ('bd292366-af32-4ba6-bdf0-11d6d1a217f3', +'7eb71370d8497734ec78dfe613bf0f08967e206d2b5c2fc1243be823cfcd57a7', +null, +'2022-08-18 08:28:18', +'2022-08-18 07:28:18', +'00b1517f-2467-4aaf-b0a5-82b4a27dcaf5', +'d4718a67-aec2-418d-8173-6ebc7bde3b86', +'2022-08-18 07:28:18', +'2022-08-18 07:28:18', +'0c175792-3aad-4795-ad03-972e8a88f94c' +) diff --git a/persistence/sql/migratest/testdata/20230707133701_testdata.sql b/persistence/sql/migratest/testdata/20230707133701_testdata.sql new file mode 100644 index 000000000000..54dd77c78ff6 --- /dev/null +++ b/persistence/sql/migratest/testdata/20230707133701_testdata.sql @@ -0,0 +1,22 @@ +INSERT INTO selfservice_registration_flows (id, nid, request_url, issued_at, expires_at, active_method, csrf_token, + created_at, updated_at, type, ui, internal_context, oauth2_login_challenge) +VALUES ('69c80296-36cd-4afc-921a-15369cac5bf0', '884f556e-eb3a-4b9f-bee3-11345642c6c0', + 'http://kratos:4433/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge/self-service/browser/flows/registration?login_challenge=', + '2013-10-07 08:23:19', '2013-10-07 08:23:19', + 'password', 'vYYuhWXBfXKzBC+BlnbDmXfBKsUWY6SU/v04gHF9GYzPjFP51RXDPOc57R7Dpbf+XLkbPNAkmem33Crz/avdrw==', + '2013-10-07 08:23:19', '2013-10-07 08:23:19', 'browser', '{}', '{"foo":"bar"}', + '3caddfd5-9903-4bce-83ff-cae36f42dff7'); + +INSERT INTO identity_registration_codes (id, address, code, used_at, expires_at, issued_at, selfservice_registration_flow_id, + created_at, updated_at, nid) +VALUES ('f1f66a69-ce02-4a12-9591-9e02dda30a0d', +'example@example.com', +'7eb71370d8497734ec78dfe613bf0f08967e206d2b5c2fc1243be823cfcd57a7', +null, +'2022-08-18 08:28:18', +'2022-08-18 07:28:18', +'69c80296-36cd-4afc-921a-15369cac5bf0', +'2022-08-18 07:28:18', +'2022-08-18 07:28:18', +'884f556e-eb3a-4b9f-bee3-11345642c6c0' +) diff --git a/persistence/sql/migrations/sql/20230703143600000000_selfservice_login_flows_state.down.sql b/persistence/sql/migrations/sql/20230703143600000000_selfservice_login_flows_state.down.sql new file mode 100644 index 000000000000..b6fccdd3cde5 --- /dev/null +++ b/persistence/sql/migrations/sql/20230703143600000000_selfservice_login_flows_state.down.sql @@ -0,0 +1 @@ +ALTER table selfservice_login_flows DROP COLUMN state; diff --git a/persistence/sql/migrations/sql/20230703143600000000_selfservice_login_flows_state.up.sql b/persistence/sql/migrations/sql/20230703143600000000_selfservice_login_flows_state.up.sql new file mode 100644 index 000000000000..47a2d43c0e7d --- /dev/null +++ b/persistence/sql/migrations/sql/20230703143600000000_selfservice_login_flows_state.up.sql @@ -0,0 +1 @@ +ALTER table selfservice_login_flows ADD state VARCHAR(255) NOT NULL DEFAULT ''; diff --git a/persistence/sql/migrations/sql/20230703143600000001_selfservice_registration_flows_state.down.sql b/persistence/sql/migrations/sql/20230703143600000001_selfservice_registration_flows_state.down.sql new file mode 100644 index 000000000000..8aab1bc395c4 --- /dev/null +++ b/persistence/sql/migrations/sql/20230703143600000001_selfservice_registration_flows_state.down.sql @@ -0,0 +1 @@ +ALTER table selfservice_registration_flows DROP COLUMN state; diff --git a/persistence/sql/migrations/sql/20230703143600000001_selfservice_registration_flows_state.up.sql b/persistence/sql/migrations/sql/20230703143600000001_selfservice_registration_flows_state.up.sql new file mode 100644 index 000000000000..b2a696532fc0 --- /dev/null +++ b/persistence/sql/migrations/sql/20230703143600000001_selfservice_registration_flows_state.up.sql @@ -0,0 +1 @@ +ALTER table selfservice_registration_flows ADD state VARCHAR(255) NOT NULL DEFAULT ''; diff --git a/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.down.sql b/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.down.sql new file mode 100644 index 000000000000..79a48193bfe8 --- /dev/null +++ b/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.down.sql @@ -0,0 +1,4 @@ +DROP TABLE identity_login_codes; + +ALTER TABLE selfservice_login_flows DROP submit_count; +ALTER TABLE selfservice_login_flows DROP skip_csrf_check; diff --git a/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.mysql.down.sql b/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.mysql.down.sql new file mode 100644 index 000000000000..79a48193bfe8 --- /dev/null +++ b/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.mysql.down.sql @@ -0,0 +1,4 @@ +DROP TABLE identity_login_codes; + +ALTER TABLE selfservice_login_flows DROP submit_count; +ALTER TABLE selfservice_login_flows DROP skip_csrf_check; diff --git a/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.mysql.up.sql b/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.mysql.up.sql new file mode 100644 index 000000000000..f1f602fbf5e7 --- /dev/null +++ b/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.mysql.up.sql @@ -0,0 +1,34 @@ +CREATE TABLE identity_login_codes +( + id CHAR(36) NOT NULL PRIMARY KEY, + code VARCHAR (64) NOT NULL, -- HMACed value of the actual code + used_at timestamp NULL DEFAULT NULL, + expires_at timestamp NOT NULL DEFAULT '2000-01-01 00:00:00', + issued_at timestamp NOT NULL DEFAULT '2000-01-01 00:00:00', + selfservice_login_flow_id CHAR(36), + identity_verifiable_address_id CHAR(36), + identity_id CHAR(36) NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + nid CHAR(36) NOT NULL, + CONSTRAINT identity_login_codes_selfservice_login_flows_id_fk + FOREIGN KEY (selfservice_login_flow_id) + REFERENCES selfservice_login_flows (id) + ON DELETE cascade, + CONSTRAINT identity_login_codes_networks_id_fk + FOREIGN KEY (nid) + REFERENCES networks (id) + ON UPDATE RESTRICT ON DELETE CASCADE, + CONSTRAINT identity_login_codes_identity_verifiable_addresses_id_fk + FOREIGN KEY (identity_verifiable_address_id) + REFERENCES identity_verifiable_addresses (id) + ON DELETE cascade +); + +CREATE INDEX identity_login_codes_nid_flow_id_idx ON identity_login_codes (nid, selfservice_login_flow_id); +CREATE INDEX identity_login_codes_identity_verifiable_address_id_idx ON identity_login_codes (identity_verifiable_address_id); +CREATE INDEX identity_login_codes_id_nid_idx ON identity_login_codes (id, nid); + + +ALTER TABLE selfservice_login_flows ADD submit_count int NOT NULL DEFAULT 0; +ALTER TABLE selfservice_login_flows ADD skip_csrf_check boolean NOT NULL DEFAULT FALSE; diff --git a/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.up.sql b/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.up.sql new file mode 100644 index 000000000000..1f4ddf6d8d2d --- /dev/null +++ b/persistence/sql/migrations/sql/20230707133700000000_identity_login_code.up.sql @@ -0,0 +1,33 @@ +CREATE TABLE identity_login_codes +( + id UUID NOT NULL PRIMARY KEY, + code VARCHAR (64) NOT NULL, -- HMACed value of the actual code + used_at timestamp NULL DEFAULT NULL, + expires_at timestamp NOT NULL DEFAULT '2000-01-01 00:00:00', + issued_at timestamp NOT NULL DEFAULT '2000-01-01 00:00:00', + selfservice_login_flow_id UUID NOT NULL, + identity_verifiable_address_id UUID NOT NULL, + identity_id UUID NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + nid UUID NOT NULL, + CONSTRAINT identity_login_codes_selfservice_login_flows_id_fk + FOREIGN KEY (selfservice_login_flow_id) + REFERENCES selfservice_login_flows (id) + ON DELETE cascade, + CONSTRAINT identity_login_codes_networks_id_fk + FOREIGN KEY (nid) + REFERENCES networks (id) + ON UPDATE RESTRICT ON DELETE CASCADE, + CONSTRAINT identity_login_codes_identity_verifiable_addresses_id_fk + FOREIGN KEY (identity_verifiable_address_id) + REFERENCES identity_verifiable_addresses (id) + ON DELETE cascade +); + +CREATE INDEX identity_login_codes_nid_flow_id_idx ON identity_login_codes (nid, selfservice_login_flow_id); +CREATE INDEX identity_login_codes_identity_verifiable_address_id_idx ON identity_login_codes (identity_verifiable_address_id); +CREATE INDEX identity_login_codes_id_nid_idx ON identity_login_codes (id, nid); + +ALTER TABLE selfservice_login_flows ADD submit_count int NOT NULL DEFAULT 0; +ALTER TABLE selfservice_login_flows ADD skip_csrf_check boolean NOT NULL DEFAULT FALSE; diff --git a/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.down.sql b/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.down.sql new file mode 100644 index 000000000000..cca834d74de3 --- /dev/null +++ b/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.down.sql @@ -0,0 +1,4 @@ +DROP TABLE identity_registration_codes; + +ALTER TABLE selfservice_registration_flows DROP submit_count; +ALTER TABLE selfservice_registration_flows DROP skip_csrf_check; diff --git a/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.mysql.down.sql b/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.mysql.down.sql new file mode 100644 index 000000000000..cca834d74de3 --- /dev/null +++ b/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.mysql.down.sql @@ -0,0 +1,4 @@ +DROP TABLE identity_registration_codes; + +ALTER TABLE selfservice_registration_flows DROP submit_count; +ALTER TABLE selfservice_registration_flows DROP skip_csrf_check; diff --git a/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.mysql.up.sql b/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.mysql.up.sql new file mode 100644 index 000000000000..93792bdf4f2d --- /dev/null +++ b/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.mysql.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE identity_registration_codes +( + id CHAR(36) NOT NULL PRIMARY KEY, + code VARCHAR (64) NOT NULL, -- HMACed value of the actual code + address VARCHAR(255) NOT NULL, + used_at timestamp NULL DEFAULT NULL, + expires_at timestamp NOT NULL DEFAULT '2000-01-01 00:00:00', + issued_at timestamp NOT NULL DEFAULT '2000-01-01 00:00:00', + selfservice_registration_flow_id CHAR(36), + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + nid CHAR(36) NOT NULL, + CONSTRAINT identity_registration_codes_selfservice_registration_flows_id_fk + FOREIGN KEY (selfservice_registration_flow_id) + REFERENCES selfservice_registration_flows (id) + ON DELETE cascade, + CONSTRAINT identity_registration_codes_networks_id_fk + FOREIGN KEY (nid) + REFERENCES networks (id) + ON UPDATE RESTRICT ON DELETE CASCADE +); + +CREATE INDEX identity_registration_codes_nid_flow_id_idx ON identity_registration_codes (nid, selfservice_registration_flow_id); +CREATE INDEX identity_registration_codes_id_nid_idx ON identity_registration_codes (id, nid); + +ALTER TABLE selfservice_registration_flows ADD submit_count int NOT NULL DEFAULT 0; +ALTER TABLE selfservice_registration_flows ADD skip_csrf_check boolean NOT NULL DEFAULT FALSE; diff --git a/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.up.sql b/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.up.sql new file mode 100644 index 000000000000..b036f71f7a24 --- /dev/null +++ b/persistence/sql/migrations/sql/20230707133700000001_identity_registration_code.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE identity_registration_codes +( + id UUID NOT NULL PRIMARY KEY, + code VARCHAR (64) NOT NULL, -- HMACed value of the actual code + address VARCHAR(255) NOT NULL, + used_at timestamp NULL DEFAULT NULL, + expires_at timestamp NOT NULL DEFAULT '2000-01-01 00:00:00', + issued_at timestamp NOT NULL DEFAULT '2000-01-01 00:00:00', + selfservice_registration_flow_id UUID NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + nid UUID NOT NULL, + CONSTRAINT identity_registration_codes_selfservice_registration_flows_id_fk + FOREIGN KEY (selfservice_registration_flow_id) + REFERENCES selfservice_registration_flows (id) + ON DELETE cascade, + CONSTRAINT identity_registration_codes_networks_id_fk + FOREIGN KEY (nid) + REFERENCES networks (id) + ON UPDATE RESTRICT ON DELETE CASCADE +); + +CREATE INDEX identity_registration_codes_nid_flow_id_idx ON identity_registration_codes (nid, selfservice_registration_flow_id); +CREATE INDEX identity_registration_codes_id_nid_idx ON identity_registration_codes (id, nid); + +ALTER TABLE selfservice_registration_flows ADD submit_count int NOT NULL DEFAULT 0; +ALTER TABLE selfservice_registration_flows ADD skip_csrf_check boolean NOT NULL DEFAULT FALSE; diff --git a/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.cockroach.down.sql b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.cockroach.down.sql new file mode 100644 index 000000000000..84f10f939a12 --- /dev/null +++ b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.cockroach.down.sql @@ -0,0 +1 @@ +DELETE FROM identity_credential_types WHERE name = 'code'; diff --git a/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.cockroach.up.sql b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.cockroach.up.sql new file mode 100644 index 000000000000..47e0cf0b2b34 --- /dev/null +++ b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.cockroach.up.sql @@ -0,0 +1 @@ +INSERT INTO identity_credential_types (id, name) SELECT '14f3b7e2-8725-4068-be39-8a796485fd97', 'code' WHERE NOT EXISTS ( SELECT * FROM identity_credential_types WHERE name = 'code'); diff --git a/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.mysql.down.sql b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.mysql.down.sql new file mode 100644 index 000000000000..84f10f939a12 --- /dev/null +++ b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.mysql.down.sql @@ -0,0 +1 @@ +DELETE FROM identity_credential_types WHERE name = 'code'; diff --git a/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.mysql.up.sql b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.mysql.up.sql new file mode 100644 index 000000000000..47e0cf0b2b34 --- /dev/null +++ b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.mysql.up.sql @@ -0,0 +1 @@ +INSERT INTO identity_credential_types (id, name) SELECT '14f3b7e2-8725-4068-be39-8a796485fd97', 'code' WHERE NOT EXISTS ( SELECT * FROM identity_credential_types WHERE name = 'code'); diff --git a/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.postgres.down.sql b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.postgres.down.sql new file mode 100644 index 000000000000..84f10f939a12 --- /dev/null +++ b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.postgres.down.sql @@ -0,0 +1 @@ +DELETE FROM identity_credential_types WHERE name = 'code'; diff --git a/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.postgres.up.sql b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.postgres.up.sql new file mode 100644 index 000000000000..47e0cf0b2b34 --- /dev/null +++ b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.postgres.up.sql @@ -0,0 +1 @@ +INSERT INTO identity_credential_types (id, name) SELECT '14f3b7e2-8725-4068-be39-8a796485fd97', 'code' WHERE NOT EXISTS ( SELECT * FROM identity_credential_types WHERE name = 'code'); diff --git a/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.sqlite.down.sql b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.sqlite.down.sql new file mode 100644 index 000000000000..84f10f939a12 --- /dev/null +++ b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.sqlite.down.sql @@ -0,0 +1 @@ +DELETE FROM identity_credential_types WHERE name = 'code'; diff --git a/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.sqlite.up.sql b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.sqlite.up.sql new file mode 100644 index 000000000000..47e0cf0b2b34 --- /dev/null +++ b/persistence/sql/migrations/sql/20230712173852000000_credential_types_code.sqlite.up.sql @@ -0,0 +1 @@ +INSERT INTO identity_credential_types (id, name) SELECT '14f3b7e2-8725-4068-be39-8a796485fd97', 'code' WHERE NOT EXISTS ( SELECT * FROM identity_credential_types WHERE name = 'code'); diff --git a/persistence/sql/persister_login.go b/persistence/sql/persister_login.go index 1f29a1860e35..7ea46d942e46 100644 --- a/persistence/sql/persister_login.go +++ b/persistence/sql/persister_login.go @@ -5,17 +5,22 @@ package sql import ( "context" + "crypto/subtle" "fmt" "time" "github.com/gobuffalo/pop/v6" + "github.com/pkg/errors" "github.com/gofrs/uuid" "github.com/ory/x/sqlcon" + "github.com/ory/kratos/identity" "github.com/ory/kratos/persistence/sql/update" + "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/selfservice/strategy/code" ) var _ login.FlowPersister = new(Persister) @@ -84,3 +89,123 @@ func (p *Persister) DeleteExpiredLoginFlows(ctx context.Context, expiresAt time. } return nil } + +func (p *Persister) CreateLoginCode(ctx context.Context, codeParams *code.CreateLoginCodeParams) (*code.LoginCode, error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateLoginCode") + defer span.End() + + now := time.Now() + + loginCode := &code.LoginCode{ + IdentityID: codeParams.IdentityID, + VerifiableAddressID: uuid.NullUUID{UUID: codeParams.VerifiableAddress.ID, Valid: true}, + CodeHMAC: p.hmacValue(ctx, codeParams.RawCode), + IssuedAt: now, + ExpiresAt: now.UTC().Add(p.r.Config().SelfServiceCodeMethodLifespan(ctx)), + FlowID: codeParams.FlowID, + NID: p.NetworkID(ctx), + ID: uuid.Nil, + } + + if err := p.GetConnection(ctx).Create(loginCode); err != nil { + return nil, sqlcon.HandleError(err) + } + return loginCode, nil +} + +func (p *Persister) UseLoginCode(ctx context.Context, flowID uuid.UUID, codeVal string) (*code.LoginCode, error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UseLoginCode") + defer span.End() + + var loginCode *code.LoginCode + + nid := p.NetworkID(ctx) + flowTableName := new(login.Flow).TableName(ctx) + + if err := sqlcon.HandleError(p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) (err error) { + //#nosec G201 -- TableName is static + if err := sqlcon.HandleError(tx.RawQuery(fmt.Sprintf("UPDATE %s SET submit_count = submit_count + 1 WHERE id = ? AND nid = ?", flowTableName), flowID, nid).Exec()); err != nil { + return err + } + + var submitCount int + // Because MySQL does not support "RETURNING" clauses, but we need the updated `submit_count` later on. + //#nosec G201 -- TableName is static + if err := sqlcon.HandleError(tx.RawQuery(fmt.Sprintf("SELECT submit_count FROM %s WHERE id = ? AND nid = ?", flowTableName), flowID, nid).First(&submitCount)); err != nil { + if errors.Is(err, sqlcon.ErrNoRows) { + // Return no error, as that would roll back the transaction + return nil + } + return err + } + + if submitCount > 5 { + return errors.WithStack(code.ErrCodeSubmittedTooOften) + } + + var loginCodes []code.LoginCode + if err = sqlcon.HandleError(tx.Where("nid = ? AND selfservice_login_flow_id = ?", nid, flowID).All(&loginCodes)); err != nil { + if errors.Is(err, sqlcon.ErrNoRows) { + return err + } + return nil + } + + secrets: + + for _, secret := range p.r.Config().SecretsSession(ctx) { + suppliedCode := []byte(p.hmacValueWithSecret(ctx, codeVal, secret)) + for i := range loginCodes { + code := loginCodes[i] + if subtle.ConstantTimeCompare([]byte(code.CodeHMAC), suppliedCode) == 0 { + // Not the supplied code + continue + } + loginCode = &code + break secrets + } + } + + if loginCode == nil || !loginCode.IsValid() { + // Return no error, as that would roll back the transaction + return nil + } + + var verifiableAddress identity.VerifiableAddress + if err := tx.Where("nid = ? AND id = ?", nid, loginCode.VerifiableAddressID).First(&verifiableAddress); err != nil { + if err = sqlcon.HandleError(err); !errors.Is(err, sqlcon.ErrNoRows) { + return err + } + return err + } + + loginCode.VerifiableAddress = &verifiableAddress + + //#nosec G201 -- TableName is static + return sqlcon.HandleError(tx.RawQuery(fmt.Sprintf("UPDATE %s SET used_at = ? WHERE id = ? AND nid = ?", loginCode.TableName(ctx)), time.Now().UTC(), loginCode.ID, nid).Exec()) + })); err != nil { + return nil, err + } + + if loginCode == nil { + return nil, code.ErrCodeNotFound + } + + if loginCode.IsExpired() { + return nil, flow.NewFlowExpiredError(loginCode.ExpiresAt) + } + + if loginCode.WasUsed() { + return nil, code.ErrCodeAlreadyUsed + } + + return loginCode, nil +} + +func (p *Persister) DeleteLoginCodesOfFlow(ctx context.Context, flowID uuid.UUID) error { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.DeleteLoginCodesOfFlow") + defer span.End() + + //#nosec G201 -- TableName is static + return p.GetConnection(ctx).RawQuery(fmt.Sprintf("DELETE FROM %s WHERE selfservice_login_flow_id = ? AND nid = ?", new(code.LoginCode).TableName(ctx)), flowID, p.NetworkID(ctx)).Exec() +} diff --git a/persistence/sql/persister_recovery.go b/persistence/sql/persister_recovery.go index d34a6fabd435..79d086e0009f 100644 --- a/persistence/sql/persister_recovery.go +++ b/persistence/sql/persister_recovery.go @@ -23,8 +23,10 @@ import ( "github.com/ory/x/sqlcon" ) -var _ recovery.FlowPersister = new(Persister) -var _ link.RecoveryTokenPersister = new(Persister) +var ( + _ recovery.FlowPersister = new(Persister) + _ link.RecoveryTokenPersister = new(Persister) +) func (p *Persister) CreateRecoveryFlow(ctx context.Context, r *recovery.Flow) error { ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateRecoveryFlow") @@ -186,7 +188,6 @@ func (p *Persister) UseRecoveryCode(ctx context.Context, fID uuid.UUID, codeVal flowTableName := new(recovery.Flow).TableName(ctx) if err := sqlcon.HandleError(p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) (err error) { - //#nosec G201 -- TableName is static if err := sqlcon.HandleError(tx.RawQuery(fmt.Sprintf("UPDATE %s SET submit_count = submit_count + 1 WHERE id = ? AND nid = ?", flowTableName), fID, nid).Exec()); err != nil { return err diff --git a/persistence/sql/persister_registration.go b/persistence/sql/persister_registration.go index fe7e25ceeac3..fc83cba1a0be 100644 --- a/persistence/sql/persister_registration.go +++ b/persistence/sql/persister_registration.go @@ -5,15 +5,20 @@ package sql import ( "context" + "crypto/subtle" "fmt" "time" + "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" + "github.com/pkg/errors" "github.com/ory/x/sqlcon" "github.com/ory/kratos/persistence/sql/update" + "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/selfservice/strategy/code" ) func (p *Persister) CreateRegistrationFlow(ctx context.Context, r *registration.Flow) error { @@ -64,3 +69,127 @@ func (p *Persister) DeleteExpiredRegistrationFlows(ctx context.Context, expiresA } return nil } + +func (p *Persister) CreateRegistrationCode(ctx context.Context, codeParams *code.CreateRegistrationCodeParams) (*code.RegistrationCode, error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateRegistrationCode") + defer span.End() + + now := time.Now() + + registrationCode := &code.RegistrationCode{ + Address: codeParams.Address, + CodeHMAC: p.hmacValue(ctx, codeParams.RawCode), + IssuedAt: now, + ExpiresAt: now.UTC().Add(p.r.Config().SelfServiceCodeMethodLifespan(ctx)), + FlowID: codeParams.FlowID, + NID: p.NetworkID(ctx), + ID: uuid.Nil, + } + + if err := p.GetConnection(ctx).Create(registrationCode); err != nil { + return nil, sqlcon.HandleError(err) + } + return registrationCode, nil +} + +func (p *Persister) UseRegistrationCode(ctx context.Context, flowID uuid.UUID, rawCode string) (*code.RegistrationCode, error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UseRegistrationCode") + defer span.End() + + nid := p.NetworkID(ctx) + + flowTableName := new(registration.Flow).TableName(ctx) + + var registrationCode *code.RegistrationCode + if err := sqlcon.HandleError(p.GetConnection(ctx).Transaction(func(tx *pop.Connection) error { + if err := tx.RawQuery(fmt.Sprintf("UPDATE %s SET submit_count = submit_count + 1 WHERE id = ? AND nid = ?", flowTableName), flowID, nid).Exec(); err != nil { + return err + } + + var submitCount int + // Because MySQL does not support "RETURNING" clauses, but we need the updated `submit_count` later on. + //#nosec G201 -- TableName is static + if err := sqlcon.HandleError(tx.RawQuery(fmt.Sprintf("SELECT submit_count FROM %s WHERE id = ? AND nid = ?", flowTableName), flowID, nid).First(&submitCount)); err != nil { + if errors.Is(err, sqlcon.ErrNoRows) { + // Return no error, as that would roll back the transaction + return nil + } + return err + } + + // This check prevents parallel brute force attacks to generate the recovery code + // by checking the submit count inside this database transaction. + // If the flow has been submitted more than 5 times, the transaction is aborted (regardless of whether the code was correct or not) + // and we thus give no indication whether the supplied code was correct or not. See also https://github.com/ory/kratos/pull/2645#discussion_r984732899 + if submitCount > 5 { + return errors.WithStack(code.ErrCodeSubmittedTooOften) + } + + var registrationCodes []code.RegistrationCode + if err := sqlcon.HandleError(tx.Where("nid = ? AND selfservice_registration_flow_id = ?", nid, flowID).All(®istrationCodes)); err != nil { + if errors.Is(err, sqlcon.ErrNoRows) { + // Return no error, as that would roll back the transaction + return nil + } + + return err + } + + secrets: + for _, secret := range p.r.Config().SecretsSession(ctx) { + suppliedCode := []byte(p.hmacValueWithSecret(ctx, rawCode, secret)) + for i := range registrationCodes { + code := registrationCodes[i] + if subtle.ConstantTimeCompare([]byte(code.CodeHMAC), suppliedCode) == 0 { + // Not the supplied code + continue + } + registrationCode = &code + break secrets + } + } + + if registrationCode == nil || !registrationCode.IsValid() { + // Return no error, as that would roll back the transaction + return nil + } + + //#nosec G201 -- TableName is static + return sqlcon.HandleError(tx.RawQuery(fmt.Sprintf("UPDATE %s SET used_at = ? WHERE id = ? AND nid = ?", registrationCode.TableName(ctx)), time.Now().UTC(), registrationCode.ID, nid).Exec()) + })); err != nil { + return nil, err + } + + if registrationCode == nil { + return nil, code.ErrCodeNotFound + } + + if registrationCode.IsExpired() { + return nil, flow.NewFlowExpiredError(registrationCode.ExpiresAt) + } + + if registrationCode.WasUsed() { + return nil, code.ErrCodeAlreadyUsed + } + + return registrationCode, nil +} + +func (p *Persister) DeleteRegistrationCodesOfFlow(ctx context.Context, flowID uuid.UUID) error { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.DeleteRegistrationCodesOfFlow") + defer span.End() + + //#nosec G201 -- TableName is static + return p.GetConnection(ctx).RawQuery(fmt.Sprintf("DELETE FROM %s WHERE selfservice_registration_flow_id = ? AND nid = ?", new(code.RegistrationCode).TableName(ctx)), flowID, p.NetworkID(ctx)).Exec() +} + +func (p *Persister) GetUsedRegistrationCode(ctx context.Context, flowID uuid.UUID) (*code.RegistrationCode, error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.GetUsedRegistrationCode") + defer span.End() + + var registrationCode code.RegistrationCode + if err := p.Connection(ctx).RawQuery(fmt.Sprintf("SELECT * FROM %s WHERE selfservice_registration_flow_id = ? AND used_at IS NOT NULL AND nid = ?", new(code.RegistrationCode).TableName(ctx)), flowID, p.NetworkID(ctx)).First(®istrationCode); err != nil { + return nil, sqlcon.HandleError(err) + } + return ®istrationCode, nil +} diff --git a/schema/extension.go b/schema/extension.go index 5955328c27df..0cd85f35af4c 100644 --- a/schema/extension.go +++ b/schema/extension.go @@ -30,6 +30,9 @@ type ( TOTP struct { AccountName bool `json:"account_name"` } `json:"totp"` + Code struct { + Identifier bool `json:"identifier"` + } `json:"code"` } `json:"credentials"` Verification struct { Via string `json:"via"` diff --git a/selfservice/flow/error_test.go b/selfservice/flow/error_test.go index 6502244fba69..98b1ad32e9c4 100644 --- a/selfservice/flow/error_test.go +++ b/selfservice/flow/error_test.go @@ -52,6 +52,17 @@ type testFlow struct { // // required: true UI *container.Container `json:"ui" db:"ui"` + + // Flow State + // + // The state represents the state of the verification flow. + // + // - choose_method: ask the user to choose a method (e.g. recover account via email) + // - sent_email: the email has been sent to the user + // - passed_challenge: the request was successful and the recovery challenge was passed. + // + // required: true + State State `json:"state" db:"state"` } func (t *testFlow) GetID() uuid.UUID { @@ -74,6 +85,18 @@ func (t *testFlow) GetUI() *container.Container { return t.UI } +func (t *testFlow) GetState() State { + return t.State +} + +func (t *testFlow) GetFlowName() FlowName { + return FlowName("test") +} + +func (t *testFlow) SetState(state State) { + t.State = state +} + func newTestFlow(r *http.Request, flowType Type) Flow { id := x.NewUUID() requestURL := x.RequestURL(r).String() diff --git a/selfservice/flow/flow.go b/selfservice/flow/flow.go index 6759ee3dfda7..577ec696a759 100644 --- a/selfservice/flow/flow.go +++ b/selfservice/flow/flow.go @@ -39,6 +39,9 @@ type Flow interface { GetRequestURL() string AppendTo(*url.URL) *url.URL GetUI() *container.Container + GetState() State + SetState(State) + GetFlowName() FlowName } type FlowWithRedirect interface { diff --git a/selfservice/flow/login/flow.go b/selfservice/flow/login/flow.go index a0276175e2e4..a2a103c65c3c 100644 --- a/selfservice/flow/login/flow.go +++ b/selfservice/flow/login/flow.go @@ -124,8 +124,19 @@ type Flow struct { // This is only set if the client has requested a session token exchange code, and if the flow is of type "api", // and only on creating the login flow. SessionTokenExchangeCode string `json:"session_token_exchange_code,omitempty" faker:"-" db:"-"` + + // State represents the state of this request: + // + // - choose_method: ask the user to choose a method (e.g. verify your email) + // - sent_email: the email has been sent to the user + // - passed_challenge: the request was successful and the verification challenge was passed. + // + // required: true + State State `json:"state" faker:"-" db:"state"` } +var _ flow.Flow = new(Flow) + func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Request, flowType flow.Type) (*Flow, error) { now := time.Now().UTC() id := x.NewUUID() @@ -251,3 +262,15 @@ func (f *Flow) SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (o x.SecureRedirectOverrideDefaultReturnTo(cfg.Config().SelfServiceFlowLoginReturnTo(ctx, f.Active.String())), } } + +func (f *Flow) GetState() State { + return f.State +} + +func (f *Flow) GetFlowName() flow.FlowName { + return flow.LoginFlow +} + +func (f *Flow) SetState(state State) { + f.State = state +} diff --git a/selfservice/flow/login/sort.go b/selfservice/flow/login/sort.go index 74160b8d2566..9f1a144ffc2d 100644 --- a/selfservice/flow/login/sort.go +++ b/selfservice/flow/login/sort.go @@ -15,6 +15,7 @@ func sortNodes(ctx context.Context, n node.Nodes) error { node.OpenIDConnectGroup, node.DefaultGroup, node.WebAuthnGroup, + node.CodeGroup, node.PasswordGroup, node.TOTPGroup, node.LookupGroup, diff --git a/selfservice/flow/login/state.go b/selfservice/flow/login/state.go new file mode 100644 index 000000000000..576fad6d9f05 --- /dev/null +++ b/selfservice/flow/login/state.go @@ -0,0 +1,17 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package login + +import "github.com/ory/kratos/selfservice/flow" + +// Login Flow State +// +// The state represents the state of the login flow. +// +// - choose_method: ask the user to choose a method (e.g. login account via email) +// - sent_email: the email has been sent to the user +// - passed_challenge: the request was successful and the login challenge was passed. +// +// swagger:model loginFlowState +type State = flow.State diff --git a/selfservice/flow/name.go b/selfservice/flow/name.go new file mode 100644 index 000000000000..1b766c6662f6 --- /dev/null +++ b/selfservice/flow/name.go @@ -0,0 +1,28 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package flow + +// FlowName is the flow name. +// +// The flow name can be one of: +// - 'login' +// - 'registration' +// - 'settings' +// - 'recovery' +// - 'verification' +// +// swagger:ignore +type FlowName string + +const ( + LoginFlow FlowName = "login" + RegistrationFlow FlowName = "registration" + SettingsFlow FlowName = "settings" + RecoveryFlow FlowName = "recovery" + VerificationFlow FlowName = "verification" +) + +func (t Type) String() string { + return string(t) +} diff --git a/selfservice/flow/recovery/flow.go b/selfservice/flow/recovery/flow.go index c6cfc3dfa4c9..3557c8652b8d 100644 --- a/selfservice/flow/recovery/flow.go +++ b/selfservice/flow/recovery/flow.go @@ -102,6 +102,8 @@ type Flow struct { DangerousSkipCSRFCheck bool `json:"-" faker:"-" db:"skip_csrf_check"` } +var _ flow.Flow = new(Flow) + func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Request, strategy Strategy, ft flow.Type) (*Flow, error) { now := time.Now().UTC() id := x.NewUUID() @@ -127,7 +129,7 @@ func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Reques Method: "POST", Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(), }, - State: StateChooseMethod, + State: flow.StateChooseMethod, CSRFToken: csrf, Type: ft, } @@ -222,3 +224,15 @@ func (f *Flow) AfterSave(*pop.Connection) error { func (f *Flow) GetUI() *container.Container { return f.UI } + +func (f *Flow) GetState() State { + return f.State +} + +func (f *Flow) GetFlowName() flow.FlowName { + return flow.RecoveryFlow +} + +func (f *Flow) SetState(state State) { + f.State = state +} diff --git a/selfservice/flow/recovery/flow_test.go b/selfservice/flow/recovery/flow_test.go index c2a1eb56e11d..cab497c1b9a2 100644 --- a/selfservice/flow/recovery/flow_test.go +++ b/selfservice/flow/recovery/flow_test.go @@ -54,7 +54,7 @@ func TestFlow(t *testing.T) { }) } - assert.EqualValues(t, recovery.StateChooseMethod, + assert.EqualValues(t, flow.StateChooseMethod, must(recovery.NewFlow(conf, time.Hour, "", u, nil, flow.TypeBrowser)).State) t.Run("type=return_to", func(t *testing.T) { diff --git a/selfservice/flow/recovery/handler.go b/selfservice/flow/recovery/handler.go index 90a62aab829c..6c4fd21344a4 100644 --- a/selfservice/flow/recovery/handler.go +++ b/selfservice/flow/recovery/handler.go @@ -185,7 +185,6 @@ type createBrowserRecoveryFlow struct { // 400: errorGeneric // default: errorGeneric func (h *Handler) createBrowserRecoveryFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - if !h.d.Config().SelfServiceFlowRecoveryEnabled(r.Context()) { h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Recovery is not allowed because it was disabled."))) return diff --git a/selfservice/flow/recovery/state.go b/selfservice/flow/recovery/state.go index 6b2ed0892b06..96ab9d29937e 100644 --- a/selfservice/flow/recovery/state.go +++ b/selfservice/flow/recovery/state.go @@ -3,6 +3,8 @@ package recovery +import "github.com/ory/kratos/selfservice/flow" + // Recovery Flow State // // The state represents the state of the recovery flow. @@ -12,33 +14,4 @@ package recovery // - passed_challenge: the request was successful and the recovery challenge was passed. // // swagger:model recoveryFlowState -type State string - -const ( - StateChooseMethod State = "choose_method" - StateEmailSent State = "sent_email" - StatePassedChallenge State = "passed_challenge" -) - -var states = []State{StateChooseMethod, StateEmailSent, StatePassedChallenge} - -func indexOf(current State) int { - for k, s := range states { - if s == current { - return k - } - } - return 0 -} - -func HasReachedState(expected, actual State) bool { - return indexOf(actual) >= indexOf(expected) -} - -func NextState(current State) State { - if current == StatePassedChallenge { - return StatePassedChallenge - } - - return states[indexOf(current)+1] -} +type State = flow.State diff --git a/selfservice/flow/registration/flow.go b/selfservice/flow/registration/flow.go index 26c748760ce2..9c227978c8a8 100644 --- a/selfservice/flow/registration/flow.go +++ b/selfservice/flow/registration/flow.go @@ -115,8 +115,19 @@ type Flow struct { // This is only set if the client has requested a session token exchange code, and if the flow is of type "api", // and only on creating the flow. SessionTokenExchangeCode string `json:"session_token_exchange_code,omitempty" faker:"-" db:"-"` + + // State represents the state of this request: + // + // - choose_method: ask the user to choose a method (e.g. registration with email) + // - sent_email: the email has been sent to the user + // - passed_challenge: the request was successful and the registration challenge was passed. + // + // required: true + State State `json:"state" faker:"-" db:"state"` } +var _ flow.Flow = new(Flow) + func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Request, ft flow.Type) (*Flow, error) { now := time.Now().UTC() id := x.NewUUID() @@ -238,3 +249,15 @@ func (f *Flow) SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (o x.SecureRedirectOverrideDefaultReturnTo(cfg.Config().SelfServiceFlowRegistrationReturnTo(ctx, f.Active.String())), } } + +func (f *Flow) GetState() State { + return f.State +} + +func (f *Flow) GetFlowName() flow.FlowName { + return flow.RegistrationFlow +} + +func (f *Flow) SetState(state State) { + f.State = state +} diff --git a/selfservice/flow/registration/sort.go b/selfservice/flow/registration/sort.go index db44e96274e3..15348dd56512 100644 --- a/selfservice/flow/registration/sort.go +++ b/selfservice/flow/registration/sort.go @@ -16,6 +16,7 @@ func SortNodes(ctx context.Context, n node.Nodes, schemaRef string) error { node.DefaultGroup, node.OpenIDConnectGroup, node.WebAuthnGroup, + node.CodeGroup, node.PasswordGroup, }), node.SortUpdateOrder(node.PasswordLoginOrder), diff --git a/selfservice/flow/registration/state.go b/selfservice/flow/registration/state.go new file mode 100644 index 000000000000..0a46f48fec3a --- /dev/null +++ b/selfservice/flow/registration/state.go @@ -0,0 +1,15 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package registration + +import "github.com/ory/kratos/selfservice/flow" + +// State represents the state of this request: +// +// - choose_method: ask the user to choose a method (e.g. registration with email) +// - sent_email: the email has been sent to the user +// - passed_challenge: the request was successful and the registration challenge was passed. +// +// required: true +type State = flow.State diff --git a/selfservice/flow/request.go b/selfservice/flow/request.go index af1b31968caa..3a68673af580 100644 --- a/selfservice/flow/request.go +++ b/selfservice/flow/request.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" "github.com/ory/kratos/selfservice/strategy" "github.com/ory/x/decoderx" @@ -25,6 +26,7 @@ var methodSchema []byte var ErrOriginHeaderNeedsBrowserFlow = herodot.ErrBadRequest. WithReasonf(`The HTTP Request Header included the "Origin" key, indicating that this request was made as part of an AJAX request in a Browser. The flow however was initiated as an API request. To prevent potential misuse and mitigate several attack vectors including CSRF, the request has been blocked. Please consult the documentation.`) + var ErrCookieHeaderNeedsBrowserFlow = herodot.ErrBadRequest. WithReasonf(`The HTTP Request Header included the "Cookie" key, indicating that this request was made by a Browser. The flow however was initiated as an API request. To prevent potential misuse and mitigate several attack vectors including CSRF, the request has been blocked. Please consult the documentation.`) @@ -76,9 +78,10 @@ func EnsureCSRF(reg interface { var dec = decoderx.NewHTTP() -func MethodEnabledAndAllowedFromRequest(r *http.Request, expected string, d interface { +func MethodEnabledAndAllowedFromRequest(r *http.Request, flow FlowName, expected string, d interface { config.Provider -}) error { +}, +) error { var method struct { Method string `json:"method" form:"method"` } @@ -96,18 +99,37 @@ func MethodEnabledAndAllowedFromRequest(r *http.Request, expected string, d inte return errors.WithStack(err) } - return MethodEnabledAndAllowed(r.Context(), expected, method.Method, d) + return MethodEnabledAndAllowed(r.Context(), flow, expected, method.Method, d) } -func MethodEnabledAndAllowed(ctx context.Context, expected, actual string, d interface { +// TODO: to disable specific flows we need to pass down the flow somehow to this method +// we could do this by adding an additional parameter, but not all methods have access to the flow +// this adds a lot of refactoring work, so we should think about a better way to do this +func MethodEnabledAndAllowed(ctx context.Context, flowName FlowName, expected, actual string, d interface { config.Provider -}) error { +}, +) error { if actual != expected { return errors.WithStack(ErrStrategyNotResponsible) } - if !d.Config().SelfServiceStrategy(ctx, expected).Enabled { - return errors.WithStack(herodot.ErrNotFound.WithReason(strategy.EndpointDisabledMessage)) + var ok bool + + if strings.EqualFold(actual, identity.CredentialsTypeCodeAuth.String()) { + switch flowName { + case RegistrationFlow: + ok = d.Config().SelfServiceCodeStrategy(ctx).RegistrationEnabled + case LoginFlow: + ok = d.Config().SelfServiceCodeStrategy(ctx).LoginEnabled + default: + ok = d.Config().SelfServiceCodeStrategy(ctx).Enabled + } + } else { + ok = d.Config().SelfServiceStrategy(ctx, expected).Enabled + } + + if !ok { + return herodot.ErrNotFound.WithReason(strategy.EndpointDisabledMessage) } return nil diff --git a/selfservice/flow/request_test.go b/selfservice/flow/request_test.go index d240f9c15daf..4fa39a61bc46 100644 --- a/selfservice/flow/request_test.go +++ b/selfservice/flow/request_test.go @@ -55,7 +55,7 @@ func TestMethodEnabledAndAllowed(t *testing.T) { ctx := context.Background() conf, d := internal.NewFastRegistryWithMocks(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := flow.MethodEnabledAndAllowedFromRequest(r, "password", d); err != nil { + if err := flow.MethodEnabledAndAllowedFromRequest(r, flow.LoginFlow, "password", d); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -91,3 +91,75 @@ func TestMethodEnabledAndAllowed(t *testing.T) { assert.Contains(t, string(body), "The requested resource could not be found") }) } + +func TestMethodCodeEnabledAndAllowed(t *testing.T) { + ctx := context.Background() + conf, d := internal.NewFastRegistryWithMocks(t) + + currentFlow := make(chan flow.FlowName, 1) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f := <-currentFlow + if err := flow.MethodEnabledAndAllowedFromRequest(r, f, "code", d); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + })) + + t.Run("login code allowed", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.login_enabled", true) + currentFlow <- flow.LoginFlow + res, err := ts.Client().PostForm(ts.URL, url.Values{"method": {"code"}}) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + assert.Equal(t, http.StatusNoContent, res.StatusCode) + }) + + t.Run("login code not allowed", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.login_enabled", false) + currentFlow <- flow.LoginFlow + res, err := ts.Client().PostForm(ts.URL, url.Values{"method": {"code"}}) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + assert.Equal(t, http.StatusInternalServerError, res.StatusCode) + assert.Contains(t, string(body), "The requested resource could not be found") + }) + + t.Run("registration code allowed", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.registration_enabled", true) + currentFlow <- flow.RegistrationFlow + res, err := ts.Client().PostForm(ts.URL, url.Values{"method": {"code"}}) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + assert.Equal(t, http.StatusNoContent, res.StatusCode) + }) + + t.Run("registration code not allowed", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.registration_enabled", false) + currentFlow <- flow.RegistrationFlow + res, err := ts.Client().PostForm(ts.URL, url.Values{"method": {"code"}}) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + assert.Equal(t, http.StatusInternalServerError, res.StatusCode) + assert.Contains(t, string(body), "The requested resource could not be found") + }) + + t.Run("recovery and verification should still be allowed if registration and login is disabled", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.registration_enabled", false) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.login_enabled", false) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.enabled", true) + + for _, f := range []flow.FlowName{flow.RecoveryFlow, flow.VerificationFlow} { + currentFlow <- f + res, err := ts.Client().PostForm(ts.URL, url.Values{"method": {"code"}}) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + assert.Equal(t, http.StatusNoContent, res.StatusCode) + } + }) +} diff --git a/selfservice/flow/settings/flow.go b/selfservice/flow/settings/flow.go index ef69a03f042e..a96da053d766 100644 --- a/selfservice/flow/settings/flow.go +++ b/selfservice/flow/settings/flow.go @@ -121,6 +121,8 @@ type Flow struct { ContinueWithItems []flow.ContinueWith `json:"continue_with,omitempty" db:"-" faker:"-" ` } +var _ flow.Flow = new(Flow) + func MustNewFlow(conf *config.Config, exp time.Duration, r *http.Request, i *identity.Identity, ft flow.Type) *Flow { f, err := NewFlow(conf, exp, r, i, ft) if err != nil { @@ -153,7 +155,7 @@ func NewFlow(conf *config.Config, exp time.Duration, r *http.Request, i *identit IdentityID: i.ID, Identity: i, Type: ft, - State: StateShowForm, + State: flow.StateShowForm, UI: &container.Container{ Method: "POST", Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(), @@ -242,3 +244,15 @@ func (f *Flow) AddContinueWith(c flow.ContinueWith) { func (f *Flow) ContinueWith() []flow.ContinueWith { return f.ContinueWithItems } + +func (f *Flow) GetState() State { + return f.State +} + +func (f *Flow) GetFlowName() flow.FlowName { + return flow.SettingsFlow +} + +func (f *Flow) SetState(state State) { + f.State = state +} diff --git a/selfservice/flow/settings/hook.go b/selfservice/flow/settings/hook.go index 166894d6eb11..c8b770e5a545 100644 --- a/selfservice/flow/settings/hook.go +++ b/selfservice/flow/settings/hook.go @@ -231,7 +231,7 @@ func (e *HookExecutor) PostSettingsHook(w http.ResponseWriter, r *http.Request, Debug("An identity's settings have been updated.") ctxUpdate.UpdateIdentity(i) - ctxUpdate.Flow.State = StateSuccess + ctxUpdate.Flow.State = flow.StateSuccess if hookOptions.cb != nil { if err := hookOptions.cb(ctxUpdate); err != nil { return err diff --git a/selfservice/flow/settings/state.go b/selfservice/flow/settings/state.go index b605cf7569d8..21cd22f303cc 100644 --- a/selfservice/flow/settings/state.go +++ b/selfservice/flow/settings/state.go @@ -3,6 +3,8 @@ package settings +import "github.com/ory/kratos/selfservice/flow" + // State represents the state of this flow. It knows two states: // // - show_form: No user data has been collected, or it is invalid, and thus the form should be shown. @@ -11,9 +13,4 @@ package settings // when a flow with invalid (e.g. "please use a valid phone number") data was sent. // // swagger:model settingsFlowState -type State string - -const ( - StateShowForm State = "show_form" - StateSuccess State = "success" -) +type State = flow.State diff --git a/selfservice/flow/state.go b/selfservice/flow/state.go new file mode 100644 index 000000000000..d3373ee95bed --- /dev/null +++ b/selfservice/flow/state.go @@ -0,0 +1,44 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package flow + +// Flow State +// +// The state represents the state of the verification flow. +// +// - choose_method: ask the user to choose a method (e.g. recover account via email) +// - sent_email: the email has been sent to the user +// - passed_challenge: the request was successful and the recovery challenge was passed. +type State string + +const ( + StateChooseMethod State = "choose_method" + StateEmailSent State = "sent_email" + StatePassedChallenge State = "passed_challenge" + StateShowForm State = "show_form" + StateSuccess State = "success" +) + +var states = []State{StateChooseMethod, StateEmailSent, StatePassedChallenge} + +func indexOf(current State) int { + for k, s := range states { + if s == current { + return k + } + } + return 0 +} + +func HasReachedState(expected, actual State) bool { + return indexOf(actual) >= indexOf(expected) +} + +func NextState(current State) State { + if current == StatePassedChallenge { + return StatePassedChallenge + } + + return states[indexOf(current)+1] +} diff --git a/selfservice/flow/recovery/state_test.go b/selfservice/flow/state_test.go similarity index 97% rename from selfservice/flow/recovery/state_test.go rename to selfservice/flow/state_test.go index 160be6e74555..349e0425ed44 100644 --- a/selfservice/flow/recovery/state_test.go +++ b/selfservice/flow/state_test.go @@ -1,7 +1,7 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -package recovery +package flow import ( "testing" diff --git a/selfservice/flow/verification/error.go b/selfservice/flow/verification/error.go index fd7542415fb1..b39875b233d0 100644 --- a/selfservice/flow/verification/error.go +++ b/selfservice/flow/verification/error.go @@ -26,9 +26,7 @@ import ( "github.com/ory/kratos/x" ) -var ( - ErrHookAbortFlow = errors.New("aborted verification hook execution") -) +var ErrHookAbortFlow = errors.New("aborted verification hook execution") type ( errorHandlerDependencies interface { diff --git a/selfservice/flow/verification/flow.go b/selfservice/flow/verification/flow.go index f5b94dd4bf95..387f7bb40fc4 100644 --- a/selfservice/flow/verification/flow.go +++ b/selfservice/flow/verification/flow.go @@ -89,6 +89,8 @@ type Flow struct { NID uuid.UUID `json:"-" faker:"-" db:"nid"` } +var _ flow.Flow = new(Flow) + func (f *Flow) GetType() flow.Type { return f.Type } @@ -127,7 +129,7 @@ func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Reques Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(), }, CSRFToken: csrf, - State: StateChooseMethod, + State: flow.StateChooseMethod, Type: ft, } @@ -253,3 +255,15 @@ func (f *Flow) ContinueURL(ctx context.Context, config *config.Config) *url.URL } return returnTo } + +func (f *Flow) GetState() State { + return f.State +} + +func (f *Flow) GetFlowName() flow.FlowName { + return flow.VerificationFlow +} + +func (f *Flow) SetState(state State) { + f.State = state +} diff --git a/selfservice/flow/verification/flow_test.go b/selfservice/flow/verification/flow_test.go index fb21b707e387..3485f3454fc4 100644 --- a/selfservice/flow/verification/flow_test.go +++ b/selfservice/flow/verification/flow_test.go @@ -64,7 +64,7 @@ func TestFlow(t *testing.T) { require.NoError(t, err) }) - assert.EqualValues(t, verification.StateChooseMethod, + assert.EqualValues(t, flow.StateChooseMethod, must(verification.NewFlow(conf, time.Hour, "", u, nil, flow.TypeBrowser)).State) } @@ -207,5 +207,4 @@ func TestContinueURL(t *testing.T) { require.Equal(t, tc.expect, url.String()) }) } - } diff --git a/selfservice/flow/verification/state.go b/selfservice/flow/verification/state.go index 9f11037cfeef..e11c91fb14d9 100644 --- a/selfservice/flow/verification/state.go +++ b/selfservice/flow/verification/state.go @@ -3,6 +3,8 @@ package verification +import "github.com/ory/kratos/selfservice/flow" + // Verification Flow State // // The state represents the state of the verification flow. @@ -11,34 +13,6 @@ package verification // - sent_email: the email has been sent to the user // - passed_challenge: the request was successful and the recovery challenge was passed. // -// swagger:model verificationFlowState -type State string - -const ( - StateChooseMethod State = "choose_method" - StateEmailSent State = "sent_email" - StatePassedChallenge State = "passed_challenge" -) - -var states = []State{StateChooseMethod, StateEmailSent, StatePassedChallenge} -func indexOf(current State) int { - for k, s := range states { - if s == current { - return k - } - } - return 0 -} - -func HasReachedState(expected, actual State) bool { - return indexOf(actual) >= indexOf(expected) -} - -func NextState(current State) State { - if current == StatePassedChallenge { - return StatePassedChallenge - } - - return states[indexOf(current)+1] -} +// swagger:model verificationFlowState +type State = flow.State diff --git a/selfservice/flow/verification/state_test.go b/selfservice/flow/verification/state_test.go deleted file mode 100644 index ab192d4db878..000000000000 --- a/selfservice/flow/verification/state_test.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright © 2023 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - -package verification - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestState(t *testing.T) { - assert.EqualValues(t, StateEmailSent, NextState(StateChooseMethod)) - assert.EqualValues(t, StatePassedChallenge, NextState(StateEmailSent)) - assert.EqualValues(t, StatePassedChallenge, NextState(StatePassedChallenge)) - - assert.True(t, HasReachedState(StatePassedChallenge, StatePassedChallenge)) - assert.False(t, HasReachedState(StatePassedChallenge, StateEmailSent)) - assert.False(t, HasReachedState(StateEmailSent, StateChooseMethod)) -} diff --git a/selfservice/hook/code_address_verifier.go b/selfservice/hook/code_address_verifier.go new file mode 100644 index 000000000000..d2d50e4be9ff --- /dev/null +++ b/selfservice/hook/code_address_verifier.go @@ -0,0 +1,65 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package hook + +import ( + "net/http" + + "github.com/pkg/errors" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/selfservice/flow/verification" + "github.com/ory/kratos/selfservice/strategy/code" + "github.com/ory/kratos/session" + "github.com/ory/kratos/x" +) + +type ( + codeAddressDependencies interface { + config.Provider + x.CSRFTokenGeneratorProvider + x.CSRFProvider + verification.StrategyProvider + verification.FlowPersistenceProvider + code.RegistrationCodePersistenceProvider + identity.PrivilegedPoolProvider + x.WriterProvider + } + CodeAddressVerifier struct { + r codeAddressDependencies + } +) + +var _ registration.PostHookPostPersistExecutor = new(Verifier) + +func NewCodeAddressVerifier(r codeAddressDependencies) *CodeAddressVerifier { + return &CodeAddressVerifier{r: r} +} + +func (cv *CodeAddressVerifier) ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, r *http.Request, a *registration.Flow, s *session.Session) error { + if a.Active != identity.CredentialsTypeCodeAuth { + return nil + } + + recoveryCode, err := cv.r.RegistrationCodePersister().GetUsedRegistrationCode(r.Context(), a.GetID()) + if err != nil { + return errors.WithStack(err) + } + + for idx := range s.Identity.VerifiableAddresses { + va := s.Identity.VerifiableAddresses[idx] + if !va.Verified && recoveryCode.Address == va.Value { + va.Verified = true + va.Status = identity.VerifiableAddressStatusCompleted + if err := cv.r.PrivilegedIdentityPool().UpdateVerifiableAddress(r.Context(), &va); err != nil { + return errors.WithStack(err) + } + break + } + } + + return nil +} diff --git a/selfservice/hook/verification.go b/selfservice/hook/verification.go index acae298a6f33..c34e2b1dae7f 100644 --- a/selfservice/hook/verification.go +++ b/selfservice/hook/verification.go @@ -18,8 +18,10 @@ import ( "github.com/ory/x/otelx" ) -var _ registration.PostHookPostPersistExecutor = new(Verifier) -var _ settings.PostHookPostPersistExecutor = new(Verifier) +var ( + _ registration.PostHookPostPersistExecutor = new(Verifier) + _ settings.PostHookPostPersistExecutor = new(Verifier) +) type ( verifierDependencies interface { @@ -83,7 +85,7 @@ func (e *Verifier) do(w http.ResponseWriter, r *http.Request, i *identity.Identi return err } - verificationFlow.State = verification.StateEmailSent + verificationFlow.State = flow.StateEmailSent if err := strategy.PopulateVerificationMethod(r, verificationFlow); err != nil { return err diff --git a/selfservice/hook/verification_test.go b/selfservice/hook/verification_test.go index 1013de192223..3d4195b6e0ba 100644 --- a/selfservice/hook/verification_test.go +++ b/selfservice/hook/verification_test.go @@ -22,7 +22,6 @@ import ( "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/flow/settings" - "github.com/ory/kratos/selfservice/flow/verification" "github.com/ory/kratos/selfservice/hook" "github.com/ory/kratos/session" "github.com/ory/kratos/x" @@ -94,7 +93,7 @@ func TestVerifier(t *testing.T) { expectedVerificationFlow, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, fView.ID) require.NoError(t, err) - require.Equal(t, expectedVerificationFlow.State, verification.StateEmailSent) + require.Equal(t, expectedVerificationFlow.State, flow.StateEmailSent) messages, err := reg.CourierPersister().NextMessages(context.Background(), 12) require.NoError(t, err) @@ -110,7 +109,7 @@ func TestVerifier(t *testing.T) { // Email to baz@ory.sh is skipped because it is verified already. assert.NotContains(t, recipients, "baz@ory.sh") - //these addresses will be marked as sent and won't be sent again by the settings hook + // these addresses will be marked as sent and won't be sent again by the settings hook address1, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, "foo@ory.sh") require.NoError(t, err) assert.EqualValues(t, identity.VerifiableAddressStatusSent, address1.Status) diff --git a/selfservice/strategy/code/.schema/login.schema.json b/selfservice/strategy/code/.schema/login.schema.json index 6572ba9f9d89..95680722486a 100644 --- a/selfservice/strategy/code/.schema/login.schema.json +++ b/selfservice/strategy/code/.schema/login.schema.json @@ -12,9 +12,8 @@ "code": { "type": "string" }, - "email": { - "type": "string", - "format": "email" + "identifier": { + "type": "string" }, "flow": { "type": "string", diff --git a/selfservice/strategy/code/.schema/registration.schema.json b/selfservice/strategy/code/.schema/registration.schema.json new file mode 100644 index 000000000000..db4785a0d8db --- /dev/null +++ b/selfservice/strategy/code/.schema/registration.schema.json @@ -0,0 +1,26 @@ +{ + "$id": "https://schemas.ory.sh/kratos/selfservice/strategy/code/registration.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "code" + ] + }, + "csrf_token": { + "type": "string" + }, + "code": { + "type": "string" + }, + "traits": { + "description": "This field will be overwritten in registration.go's decoder() method. Do not add anything to this field as it has no effect." + }, + "transient_payload": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json b/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json index a1993da4d3d0..6681ed6af2cf 100644 --- a/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json +++ b/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json @@ -3,8 +3,8 @@ "type": "input", "group": "code", "attributes": { - "name": "code", - "type": "text", + "name": "email", + "type": "email", "required": true, "disabled": false, "node_type": "input" @@ -12,25 +12,12 @@ "messages": [], "meta": { "label": { - "id": 1070011, - "text": "Verification code", + "id": 1070007, + "text": "Email", "type": "info" } } }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "hidden", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, { "type": "input", "group": "code", @@ -56,6 +43,7 @@ "attributes": { "name": "csrf_token", "type": "hidden", + "value": "cpIqhfoy4/wdxgPc6IQBXeN1MF9zMtv+wT+Rg0k0FR0QKZ1eqdqRub7owJfTj3N0O4gl5feI9lsnylK2Il1zlA==", "required": true, "disabled": false, "node_type": "input" @@ -63,6 +51,37 @@ "messages": [], "meta": {} }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "hidden", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + } + }, { "type": "input", "group": "code", diff --git a/selfservice/strategy/code/code_login.go b/selfservice/strategy/code/code_login.go index 9243f1015e26..40c5892b7715 100644 --- a/selfservice/strategy/code/code_login.go +++ b/selfservice/strategy/code/code_login.go @@ -1,22 +1,88 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + package code import ( + "context" "database/sql" + "time" "github.com/gofrs/uuid" + "github.com/ory/kratos/identity" ) -type LoginRegistrationCode struct { - // ID is the primary key +type LoginCode struct { + // ID represents the tokens's unique ID. // // required: true // type: string // format: uuid ID uuid.UUID `json:"id" db:"id" faker:"-"` - // CodeHMAC represents the HMACed value of the login/registration code - CodeHMAC string `json:"-" db:"code_hmac"` + // VerifiableAddress links this code to a verification address. + // required: true + VerifiableAddress *identity.VerifiableAddress `json:"verification_address" belongs_to:"identity_verifiable_addresses"` + + // CodeHMAC represents the HMACed value of the verification code + CodeHMAC string `json:"-" db:"code"` // UsedAt is the timestamp of when the code was used or null if it wasn't yet UsedAt sql.NullTime `json:"-" db:"used_at"` + + // ExpiresAt is the time (UTC) when the token expires. + // required: true + ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"` + + // IssuedAt is the time (UTC) when the token was issued. + // required: true + IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"` + + // CreatedAt is a helper struct field for gobuffalo.pop. + CreatedAt time.Time `json:"-" faker:"-" db:"created_at"` + + // UpdatedAt is a helper struct field for gobuffalo.pop. + UpdatedAt time.Time `json:"-" faker:"-" db:"updated_at"` + + // VerifiableAddressID is a helper struct field for gobuffalo.pop. + VerifiableAddressID uuid.NullUUID `json:"-" faker:"-" db:"identity_verifiable_address_id"` + + // FlowID is a helper struct field for gobuffalo.pop. + FlowID uuid.UUID `json:"-" faker:"-" db:"selfservice_login_flow_id"` + + NID uuid.UUID `json:"-" faker:"-" db:"nid"` + IdentityID uuid.UUID `json:"identity_id" faker:"-" db:"identity_id"` +} + +func (LoginCode) TableName(ctx context.Context) string { + return "identity_login_codes" +} + +func (f LoginCode) IsExpired() bool { + return f.ExpiresAt.Before(time.Now()) +} + +func (r LoginCode) WasUsed() bool { + return r.UsedAt.Valid +} + +func (f LoginCode) IsValid() bool { + return !f.IsExpired() && !f.WasUsed() +} + +type CreateLoginCodeParams struct { + // Code represents the recovery code + RawCode string + + // ExpiresAt is the time (UTC) when the code expires. + // required: true + ExpiresIn time.Duration + + // VerifiableAddress links this code to a verification address. + VerifiableAddress *identity.VerifiableAddress + + // FlowID is a helper struct field for gobuffalo.pop. + FlowID uuid.UUID + + IdentityID uuid.UUID } diff --git a/selfservice/strategy/code/code_registration.go b/selfservice/strategy/code/code_registration.go new file mode 100644 index 000000000000..a7126094eed0 --- /dev/null +++ b/selfservice/strategy/code/code_registration.go @@ -0,0 +1,73 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package code + +import ( + "context" + "database/sql" + "time" + + "github.com/gofrs/uuid" +) + +type RegistrationCode struct { + // ID represents the tokens's unique ID. + // + // required: true + // type: string + // format: uuid + ID uuid.UUID `json:"id" db:"id" faker:"-"` + + // Address represents the address that the code was sent to. + // this can be an email address or a phone number. + Address string `json:"-" db:"address"` + + // CodeHMAC represents the HMACed value of the verification code + CodeHMAC string `json:"-" db:"code"` + + // UsedAt is the timestamp of when the code was used or null if it wasn't yet + UsedAt sql.NullTime `json:"-" db:"used_at"` + + // ExpiresAt is the time (UTC) when the token expires. + // required: true + ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"` + + // IssuedAt is the time (UTC) when the token was issued. + // required: true + IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"` + + // CreatedAt is a helper struct field for gobuffalo.pop. + CreatedAt time.Time `json:"-" faker:"-" db:"created_at"` + + // UpdatedAt is a helper struct field for gobuffalo.pop. + UpdatedAt time.Time `json:"-" faker:"-" db:"updated_at"` + + // FlowID is a helper struct field for gobuffalo.pop. + FlowID uuid.UUID `json:"-" faker:"-" db:"selfservice_registration_flow_id"` + + NID uuid.UUID `json:"-" faker:"-" db:"nid"` +} + +func (RegistrationCode) TableName(ctx context.Context) string { + return "identity_registration_codes" +} + +func (f RegistrationCode) IsExpired() bool { + return f.ExpiresAt.Before(time.Now()) +} + +func (r RegistrationCode) WasUsed() bool { + return r.UsedAt.Valid +} + +func (f RegistrationCode) IsValid() bool { + return !f.IsExpired() && !f.WasUsed() +} + +type CreateRegistrationCodeParams struct { + RawCode string + ExpiresIn time.Duration + FlowID uuid.UUID + Address string +} diff --git a/selfservice/strategy/code/code_sender.go b/selfservice/strategy/code/code_sender.go index 5f48437131b5..e1472d9a36f3 100644 --- a/selfservice/strategy/code/code_sender.go +++ b/selfservice/strategy/code/code_sender.go @@ -22,7 +22,9 @@ import ( "github.com/ory/kratos/courier" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/selfservice/flow/recovery" + "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/flow/verification" "github.com/ory/kratos/x" ) @@ -40,6 +42,8 @@ type ( RecoveryCodePersistenceProvider VerificationCodePersistenceProvider + RegistrationCodePersistenceProvider + LoginCodePersistenceProvider HTTPClient(ctx context.Context, opts ...httpx.ResilientOptions) *retryablehttp.Client } @@ -137,6 +141,107 @@ func (s *Sender) SendRecoveryCodeTo(ctx context.Context, i *identity.Identity, c return s.send(ctx, string(code.RecoveryAddress.Via), email.NewRecoveryCodeValid(s.deps, &emailModel)) } +func (s *Sender) SendRegistrationCode(ctx context.Context, f *registration.Flow, id *identity.Identity, to ...string) error { + s.deps.Logger(). + WithSensitiveField("address", to). + Debug("Preparing registration code.") + + for _, address := range to { + rawCode := GenerateCode() + + code, err := s.deps. + RegistrationCodePersister(). + CreateRegistrationCode(ctx, &CreateRegistrationCodeParams{ + RawCode: rawCode, + ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx), + FlowID: f.ID, + Address: address, + }) + if err != nil { + return err + } + + if err := s.SendRegistrationCodeTo(ctx, address, id, rawCode, code); err != nil { + return err + } + } + return nil +} + +func (s *Sender) SendRegistrationCodeTo(ctx context.Context, to string, i *identity.Identity, codeString string, code *RegistrationCode) error { + s.deps.Audit(). + WithField("registration_flow_id", code.FlowID). + WithField("registration_code_id", code.ID). + WithSensitiveField("registration_code", codeString). + Info("Sending out registration email with code.") + + model, err := x.StructToMap(i.Traits) + if err != nil { + return err + } + + emailModel := email.RegistrationCodeValidModel{ + To: to, + RegistrationCode: codeString, + Traits: model, + } + + return s.send(ctx, string(identity.AddressTypeEmail), email.NewRegistrationCodeValid(s.deps, &emailModel)) +} + +func (s *Sender) SendLoginCode(ctx context.Context, f *login.Flow, i *identity.Identity, to ...string) error { + for _, address := range to { + rawCode := GenerateCode() + + var va identity.VerifiableAddress + for _, v := range i.VerifiableAddresses { + if v.Value == address { + va = v + } + } + + code, err := s.deps. + LoginCodePersister(). + CreateLoginCode(ctx, &CreateLoginCodeParams{ + VerifiableAddress: &va, + RawCode: rawCode, + ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx), + FlowID: f.ID, + IdentityID: i.ID, + }) + if err != nil { + return err + } + + if err := s.SendLoginCodeTo(ctx, address, i, rawCode, code); err != nil { + return err + } + } + + return nil +} + +func (s *Sender) SendLoginCodeTo(ctx context.Context, address string, i *identity.Identity, rawCode string, code *LoginCode) error { + s.deps.Audit(). + WithField("login_flow_id", code.FlowID). + WithField("login_code_id", code.ID). + WithSensitiveField("login_code", rawCode). + Info("Sending out login email with code.") + + model, err := x.StructToMap(i) + if err != nil { + return err + } + + emailModel := email.LoginCodeValidModel{ + To: address, + LoginCode: rawCode, + Identity: model, + } + + return s.send(ctx, string(identity.AddressTypeEmail), email.NewLoginCodeValid(s.deps, &emailModel)) +} + // SendVerificationCode sends a verification code & link to the specified address // // If the address does not exist in the store and dispatching invalid emails is enabled (CourierEnableInvalidDispatch is diff --git a/selfservice/strategy/code/code_sender_test.go b/selfservice/strategy/code/code_sender_test.go index 6b74bf4f1c53..e5ba75826eb5 100644 --- a/selfservice/strategy/code/code_sender_test.go +++ b/selfservice/strategy/code/code_sender_test.go @@ -48,7 +48,6 @@ func TestSender(t *testing.T) { require.NoError(t, reg.IdentityManager().Create(ctx, i)) t.Run("method=SendRecoveryCode", func(t *testing.T) { - recoveryCode := func(t *testing.T) { t.Helper() f, err := recovery.NewFlow(conf, time.Hour, "", u, code.NewStrategy(reg), flow.TypeBrowser) @@ -101,7 +100,6 @@ func TestSender(t *testing.T) { assert.Equal(t, messages[1].Subject, subject+" invalid") assert.Equal(t, messages[1].Body, body) }) - }) t.Run("method=SendVerificationCode", func(t *testing.T) { @@ -198,7 +196,6 @@ func TestSender(t *testing.T) { }, } { t.Run("strategy="+tc.flow, func(t *testing.T) { - conf.Set(ctx, tc.configKey, false) t.Cleanup(func() { @@ -214,5 +211,4 @@ func TestSender(t *testing.T) { }) } }) - } diff --git a/selfservice/strategy/code/persistence.go b/selfservice/strategy/code/persistence.go index b64a6bbfb0c8..9c8b8a107947 100644 --- a/selfservice/strategy/code/persistence.go +++ b/selfservice/strategy/code/persistence.go @@ -29,4 +29,25 @@ type ( VerificationCodePersistenceProvider interface { VerificationCodePersister() VerificationCodePersister } + + RegistrationCodePersistenceProvider interface { + RegistrationCodePersister() RegistrationCodePersister + } + + RegistrationCodePersister interface { + CreateRegistrationCode(context.Context, *CreateRegistrationCodeParams) (*RegistrationCode, error) + UseRegistrationCode(ctx context.Context, flowID uuid.UUID, code string) (*RegistrationCode, error) + DeleteRegistrationCodesOfFlow(ctx context.Context, flowID uuid.UUID) error + GetUsedRegistrationCode(ctx context.Context, flowID uuid.UUID) (*RegistrationCode, error) + } + + LoginCodePersistenceProvider interface { + LoginCodePersister() LoginCodePersister + } + + LoginCodePersister interface { + CreateLoginCode(context.Context, *CreateLoginCodeParams) (*LoginCode, error) + UseLoginCode(ctx context.Context, flowID uuid.UUID, code string) (*LoginCode, error) + DeleteLoginCodesOfFlow(ctx context.Context, flowID uuid.UUID) error + } ) diff --git a/selfservice/strategy/code/schema.go b/selfservice/strategy/code/schema.go index 24674c9a476d..d3ec2c66cf81 100644 --- a/selfservice/strategy/code/schema.go +++ b/selfservice/strategy/code/schema.go @@ -15,3 +15,6 @@ var verificationMethodSchema []byte //go:embed .schema/login.schema.json var loginMethodSchema []byte + +//go:embed .schema/registration.schema.json +var registrationSchema []byte diff --git a/selfservice/strategy/code/strategy.go b/selfservice/strategy/code/strategy.go index 262cc40e8963..3d513e3e0e9a 100644 --- a/selfservice/strategy/code/strategy.go +++ b/selfservice/strategy/code/strategy.go @@ -4,34 +4,50 @@ package code import ( + "context" + "encoding/json" + "net/http" + + "github.com/ory/kratos/continuity" "github.com/ory/kratos/courier" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/errorx" + "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/selfservice/flow/verification" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/session" + "github.com/ory/kratos/text" "github.com/ory/kratos/ui/container" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" "github.com/ory/x/decoderx" "github.com/ory/x/randx" + "github.com/ory/x/urlx" + "github.com/pkg/errors" ) -var _ recovery.Strategy = new(Strategy) -var _ recovery.AdminHandler = new(Strategy) -var _ recovery.PublicHandler = new(Strategy) +var ( + _ recovery.Strategy = new(Strategy) + _ recovery.AdminHandler = new(Strategy) + _ recovery.PublicHandler = new(Strategy) +) -var _ verification.Strategy = new(Strategy) -var _ verification.AdminHandler = new(Strategy) -var _ verification.PublicHandler = new(Strategy) +var ( + _ verification.Strategy = new(Strategy) + _ verification.AdminHandler = new(Strategy) + _ verification.PublicHandler = new(Strategy) +) -var _ login.Strategy = new(Strategy) -var _ registration.Strategy = new(Strategy) +var ( + _ login.Strategy = new(Strategy) + _ registration.Strategy = new(Strategy) +) type ( // FlowMethod contains the configuration for this selfservice strategy. @@ -71,16 +87,23 @@ type ( verification.HookExecutorProvider login.StrategyProvider - login.HookExecutorProvider login.FlowPersistenceProvider registration.StrategyProvider + registration.FlowPersistenceProvider RecoveryCodePersistenceProvider VerificationCodePersistenceProvider SenderProvider + RegistrationCodePersistenceProvider + LoginCodePersistenceProvider + schema.IdentityTraitsProvider + + sessiontokenexchange.PersistenceProvider + + continuity.ManagementProvider } Strategy struct { @@ -89,14 +112,230 @@ type ( } ) +const ( + continuitySessionName = "ory_kratos_registration_auth_code_session" +) + func NewStrategy(deps strategyDependencies) *Strategy { return &Strategy{deps: deps, dx: decoderx.NewHTTP()} } +func (s *Strategy) ID() identity.CredentialsType { + return identity.CredentialsTypeCodeAuth +} + func (s *Strategy) NodeGroup() node.UiNodeGroup { return node.CodeGroup } +func (s *Strategy) PopulateMethod(r *http.Request, f flow.Flow) error { + if string(f.GetState()) == "" { + f.SetState(flow.StateChooseMethod) + } + + switch f.GetFlowName() { + case flow.VerificationFlow, flow.RecoveryFlow: + f.GetUI().ResetMessages() + break + } + + switch f.GetState() { + case flow.StateChooseMethod: + + if f.GetFlowName() == flow.VerificationFlow || f.GetFlowName() == flow.RecoveryFlow { + f.GetUI().GetNodes().Upsert( + node.NewInputField("email", nil, node.CodeGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute). + WithMetaLabel(text.NewInfoNodeInputEmail()), + ) + } else if f.GetFlowName() == flow.LoginFlow { + // we use the identifier label here since we don't know what + // type of field the identifier is + f.GetUI().GetNodes().Upsert( + node.NewInputField("identifier", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). + WithMetaLabel(text.NewInfoNodeLabelID()), + ) + } + + var codeMetaLabel *text.Message + + switch f.GetFlowName() { + case flow.VerificationFlow, flow.RecoveryFlow: + codeMetaLabel = text.NewInfoNodeLabelSubmit() + case flow.LoginFlow: + codeMetaLabel = text.NewInfoSelfServiceLoginCode() + case flow.RegistrationFlow: + codeMetaLabel = text.NewInfoSelfServiceRegistrationRegisterCode() + } + + f.GetUI().Nodes.Append( + node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit). + WithMetaLabel(codeMetaLabel), + ) + + case flow.StateEmailSent: + var codeMetaLabel *text.Message + var message *text.Message + + switch f.GetFlowName() { + case flow.RecoveryFlow: + codeMetaLabel = text.NewInfoNodeLabelRecoveryCode() + message = text.NewRecoveryEmailWithCodeSent() + case flow.VerificationFlow: + codeMetaLabel = text.NewInfoNodeLabelVerificationCode() + message = text.NewVerificationEmailWithCodeSent() + case flow.LoginFlow: + codeMetaLabel = text.NewInfoNodeLabelLoginCode() + message = text.NewLoginEmailWithCodeSent() + case flow.RegistrationFlow: + codeMetaLabel = text.NewInfoNodeLabelRegistrationCode() + message = text.NewRegistrationEmailWithCodeSent() + } + + f.GetUI().Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). + WithMetaLabel(codeMetaLabel)) + + // Required for the re-send code button + f.GetUI().Nodes.Append( + node.NewInputField("method", s.NodeGroup(), node.CodeGroup, node.InputAttributeTypeHidden), + ) + + f.GetUI().Messages.Set(message) + } + + if f.GetType() == flow.TypeBrowser { + f.GetUI().SetCSRF(s.deps.GenerateCSRFToken(r)) + } + return nil +} + +// NewCodeUINodes creates a fresh UI for the code flow. +// this is used with the `recovery`, `verification`, `registration` and `login` flows. +func (s *Strategy) NewCodeUINodes(r *http.Request, f flow.Flow, traits json.RawMessage) error { + + var route string + var message *text.Message + + nodes := node.Nodes{} + + switch f.GetFlowName() { + case flow.RecoveryFlow: + route = recovery.RouteSubmitFlow + message = text.NewRecoveryEmailWithCodeSent() + case flow.VerificationFlow: + route = verification.RouteSubmitFlow + message = text.NewVerificationEmailWithCodeSent() + case flow.LoginFlow: + route = login.RouteSubmitFlow + message = text.NewLoginEmailWithCodeSent() + case flow.RegistrationFlow: + // in registration flow we just remove nodes that are not of the code group + for _, n := range f.GetUI().Nodes { + if n.Group == node.CodeGroup { + nodes = append(nodes, n) + } + } + + for _, n := range container.NewFromJSON("", node.CodeGroup, traits, "traits").Nodes { + // we only set the value and not the whole field because we want to keep types from the initial form generation + f.GetUI().Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue()) + } + + route = registration.RouteSubmitFlow + message = text.NewRegistrationEmailWithCodeSent() + default: + return errors.New("received unexpected flow type") + } + + f.GetUI().Nodes = nodes + + f.GetUI().Method = "POST" + f.GetUI().Action = flow.AppendFlowTo(urlx.AppendPaths(s.deps.Config().SelfPublicURL(r.Context()), route), f.GetID()).String() + + // Set the request's CSRF token + if f.GetType() == flow.TypeBrowser { + f.GetUI().SetCSRF(s.deps.GenerateCSRFToken(r)) + } + + f.GetUI().Messages.Set(message) + f.GetUI().Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). + WithMetaLabel(text.NewInfoNodeLabelVerifyOTP()), + ) + + f.GetUI(). + GetNodes(). + Upsert(node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit). + WithMetaLabel(text.NewInfoNodeLabelSubmit())) + + return nil +} + +type ( + CreateCodeState func(context.Context) error + ValidateCodeState func(context.Context) error + AlreadyValidatedCodeState func(context.Context) error + CodeStateManager struct { + f flow.Flow + createCodeState CreateCodeState + verifyCodeState ValidateCodeState + alreadyValidatedCodeState AlreadyValidatedCodeState + } +) + +func NewCodeStateManager(f flow.Flow) *CodeStateManager { + return &CodeStateManager{ + f: f, + } +} + +func (c *CodeStateManager) RegisterCreateCodeState(fn CreateCodeState) { + c.createCodeState = fn +} + +func (c *CodeStateManager) RegisterVerifyCodeState(fn ValidateCodeState) { + c.verifyCodeState = fn +} + +func (c *CodeStateManager) RegisterAlreadyValidatedCodeState(fn AlreadyValidatedCodeState) { + c.alreadyValidatedCodeState = fn +} + +func (c *CodeStateManager) Run(ctx context.Context) error { + // By Default the flow should be in the 'choose method' state. + if c.f.GetState() == "" { + c.f.SetState(flow.StateChooseMethod) + } + + switch c.f.GetState() { + case flow.StateChooseMethod: + if err := c.createCodeState(ctx); err != nil { + return err + } + + case flow.StateEmailSent: + if err := c.verifyCodeState(ctx); err != nil { + return err + } + case flow.StatePassedChallenge: + return c.alreadyValidatedCodeState(ctx) + default: + return errors.WithStack(errors.New("Unknown flow state")) + } + return nil +} + +func (s *Strategy) NextFlowState(f flow.Flow) { + switch f.GetState() { + case flow.StateChooseMethod: + f.SetState(flow.StateEmailSent) + case flow.StateEmailSent: + f.SetState(flow.StatePassedChallenge) + case flow.StatePassedChallenge: + f.SetState(flow.StatePassedChallenge) + default: + f.SetState(flow.StateChooseMethod) + } +} + const CodeLength = 6 func GenerateCode() string { diff --git a/selfservice/strategy/code/strategy_login.go b/selfservice/strategy/code/strategy_login.go index 126fea60d8b2..c98d15e20949 100644 --- a/selfservice/strategy/code/strategy_login.go +++ b/selfservice/strategy/code/strategy_login.go @@ -1,14 +1,18 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + package code import ( - "bytes" "context" - "encoding/json" + "database/sql" "net/http" "github.com/gofrs/uuid" - "github.com/ory/herodot" + "github.com/pkg/errors" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/session" @@ -16,7 +20,6 @@ import ( "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" "github.com/ory/x/decoderx" - "github.com/ory/x/stringsx" ) var _ login.Strategy = new(Strategy) @@ -28,17 +31,12 @@ type loginSubmitPayload struct { Identifier string `json:"identifier"` } -func (s *Strategy) RegisterLoginRoutes(*x.RouterPublic) { -} - -func (s *Strategy) ID() identity.CredentialsType { - return identity.CredentialsTypeCodeAuth -} +func (s *Strategy) RegisterLoginRoutes(*x.RouterPublic) {} func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod { return session.AuthenticationMethod{ Method: identity.CredentialsTypeCodeAuth, - AAL: identity.AuthenticatorAssuranceLevel2, + AAL: identity.AuthenticatorAssuranceLevel1, } } @@ -51,8 +49,8 @@ func (s *Strategy) HandleLoginError(w http.ResponseWriter, r *http.Request, flow flow.UI.SetCSRF(s.deps.GenerateCSRFToken(r)) flow.UI.GetNodes().Upsert( - node.NewInputField("identifier", email, node.CodeGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute). - WithMetaLabel(text.NewInfoNodeInputEmail()), + node.NewInputField("identifier", email, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). + WithMetaLabel(text.NewInfoNodeLabelID()), ) } @@ -60,20 +58,7 @@ func (s *Strategy) HandleLoginError(w http.ResponseWriter, r *http.Request, flow } func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, lf *login.Flow) error { - if lf.Type != flow.TypeBrowser { - return nil - } - - if requestedAAL == identity.AuthenticatorAssuranceLevel2 { - return nil - } - - lf.UI.SetCSRF(s.deps.GenerateCSRFToken(r)) - lf.UI.GetNodes().Upsert( - node.NewInputField("identifier", "", node.CodeGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute). - WithMetaLabel(text.NewInfoNodeInputEmail()), - ) - return nil + return s.PopulateMethod(r, lf) } func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, identityID uuid.UUID) (i *identity.Identity, err error) { @@ -81,7 +66,11 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, err } - if err := flow.MethodEnabledAndAllowedFromRequest(r, s.ID().String(), s.deps); err != nil { + if string(f.GetState()) == "" { + f.SetState(flow.StateChooseMethod) + } + + if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.deps); err != nil { return nil, err } @@ -98,21 +87,93 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, s.HandleLoginError(w, r, f, &p, err) } - i, c, err := s.deps.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), s.ID(), p.Identifier) + codeManager := NewCodeStateManager(f) - if err != nil { - return nil, s.HandleLoginError(w, r, f, &p, err) - } + codeManager.RegisterCreateCodeState(func(ctx context.Context) error { + // we need to query the verifiableaAddress table to get the identity id for the code + // since the identity doesn't "link" the code method to their account explicitly + // so we cannot query the identity by its credentials directly + verifiableAddress, err := s.deps.PrivilegedIdentityPool().FindVerifiableAddressByValue(r.Context(), identity.VerifiableAddressTypeEmail, p.Identifier) + if err != nil { + return s.HandleLoginError(w, r, f, &p, err) + } - var o identity.CredentialsOTP - d := json.NewDecoder(bytes.NewBuffer(c.Config)) - if err := d.Decode(&o); err != nil { - return nil, herodot.ErrInternalServerError.WithReason("The password credentials could not be decoded properly").WithDebug(err.Error()).WithWrap(err) - } + i, err := s.deps.PrivilegedIdentityPool().GetIdentity(r.Context(), verifiableAddress.IdentityID, identity.ExpandDefault) + if err != nil { + return s.HandleLoginError(w, r, f, &p, err) + } - f.Active = identity.CredentialsTypeCodeAuth - if err = s.deps.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil { - return nil, s.HandleLoginError(w, r, f, &p, err) + // Step 2: Delete any previous login codes for this flow ID + if err := s.deps.LoginCodePersister().DeleteLoginCodesOfFlow(ctx, f.ID); err != nil { + return s.HandleLoginError(w, r, f, &p, err) + } + + // kratos only supports email identifiers with the code method + // we assume the identifier fields coming from the `CredentialsTypeCodeAuth` are always email addresses + // since it is validated upon identity registration + if err := s.deps.CodeSender().SendLoginCode(ctx, f, i, verifiableAddress.Value); err != nil { + return s.HandleLoginError(w, r, f, &p, err) + } + + if err := s.NewCodeUINodes(r, f, nil); err != nil { + return s.HandleLoginError(w, r, f, &p, err) + } + + // sets the flow state to code sent + s.NextFlowState(f) + + f.Active = identity.CredentialsTypeCodeAuth + if err = s.deps.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil { + return s.HandleLoginError(w, r, f, &p, err) + } + + if x.IsJSONRequest(r) { + s.deps.Writer().Write(w, r, f) + } else { + http.Redirect(w, r, f.AppendTo(s.deps.Config().SelfServiceFlowLoginUI(ctx)).String(), http.StatusSeeOther) + } + + // we return an error to the flow handler so that it does not continue execution of the hooks. + // we are not done with the login flow yet. The user needs to verify the code and then we need to persist the identity. + return errors.WithStack(flow.ErrCompletedByStrategy) + }) + + codeManager.RegisterVerifyCodeState(func(ctx context.Context) error { + loginCode, err := s.deps.LoginCodePersister().UseLoginCode(ctx, f.ID, p.Code) + if err != nil { + return s.HandleLoginError(w, r, f, &p, err) + } + + i, err = s.deps.PrivilegedIdentityPool().GetIdentity(ctx, loginCode.IdentityID, identity.ExpandDefault) + if err != nil { + return s.HandleLoginError(w, r, f, &p, err) + } + + // Step 2: The code was correct + f.Active = identity.CredentialsTypeCodeAuth + + // since nothing has errored yet, we can assume that the code is correct + // and we can update the login flow + s.NextFlowState(f) + + if err := s.deps.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil { + return s.HandleLoginError(w, r, f, &p, err) + } + + if err := i.SetCredentialsWithConfig(s.ID(), identity.Credentials{Type: s.ID(), Identifiers: []string{}}, &identity.CredentialsCode{CodeHMAC: loginCode.CodeHMAC, UsedAt: sql.NullTime{}}); err != nil { + return err + } + + return nil + }) + + codeManager.RegisterAlreadyValidatedCodeState(func(ctx context.Context) error { + return s.HandleLoginError(w, r, f, &p, errors.WithStack(schema.NewNoLoginStrategyResponsible())) + }) + + if err := codeManager.Run(r.Context()); err != nil { + // the error is already handled by the registered code states + return i, err } return i, nil diff --git a/selfservice/strategy/code/strategy_login_test.go b/selfservice/strategy/code/strategy_login_test.go new file mode 100644 index 000000000000..3704c5a364a2 --- /dev/null +++ b/selfservice/strategy/code/strategy_login_test.go @@ -0,0 +1,129 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package code_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/x/sqlxx" +) + +func TestLoginCodeStrategy(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") + conf.MustSet(ctx, fmt.Sprintf("%s.%s.enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth.String()), false) + conf.MustSet(ctx, fmt.Sprintf("%s.%s.login_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth.String()), true) + conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, "https://www.ory.sh") + conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{"https://www.ory.sh"}) + + _ = testhelpers.NewLoginUIFlowEchoServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + + public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) + + createIdentity := func(t *testing.T) *identity.Identity { + t.Helper() + i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + i.Traits = identity.Traits(fmt.Sprintf(`{"email":"%s"}`, testhelpers.RandomEmail())) + credentials := map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: {Identifiers: []string{"zab", "bar"}, Type: identity.CredentialsTypePassword, Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\"}")}, + identity.CredentialsTypeOIDC: {Type: identity.CredentialsTypeOIDC, Identifiers: []string{"bar", "baz"}, Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\"}")}, + identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{"foo", "bar"}, Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\", \"user_handle\": \"rVIFaWRcTTuQLkXFmQWpgA==\"}")}, + } + i.Credentials = credentials + i.VerifiableAddresses = []identity.VerifiableAddress{{Value: gjson.Get(i.Traits.String(), "email").String(), Verified: true, Status: identity.VerifiableAddressStatusCompleted}} + + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i)) + return i + } + + t.Run("case=should be able to log in with otp without any other identity credentials", func(t *testing.T) { + identity := createIdentity(t) + client := testhelpers.NewClientWithCookies(t) + + // 1. Initiate flow + resp, err := client.Get(public.URL + login.RouteInitBrowserFlow) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + flowID := gjson.GetBytes(body, "id").String() + require.NotEmpty(t, flowID) + + csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() + require.NotEmpty(t, csrfToken) + + require.NoError(t, resp.Body.Close()) + + loginEmail := gjson.Get(identity.Traits.String(), "email").String() + payload := strings.NewReader(url.Values{ + "csrf_token": {gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()}, + "method": {"code"}, + "identifier": {loginEmail}, + }.Encode()) + + req, err := http.NewRequestWithContext(ctx, "POST", public.URL+login.RouteSubmitFlow+"?flow="+flowID, payload) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + // 2. Submit Identifier (email) + resp, err = client.Do(req) + require.NoError(t, err) + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + csrfToken = gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() + require.NotEmpty(t, csrfToken) + + require.NoError(t, resp.Body.Close()) + + message := testhelpers.CourierExpectMessage(t, reg, loginEmail, "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 + req, err = http.NewRequestWithContext(ctx, "POST", public.URL+login.RouteSubmitFlow+"?flow="+flowID, strings.NewReader(url.Values{ + "csrf_token": {csrfToken}, + "method": {"code"}, + "code": {loginCode}, + }.Encode())) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err = client.Do(req) + require.NoError(t, err) + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + var cookie *http.Cookie + for _, c := range resp.Cookies() { + cookie = c + } + require.Equal(t, cookie.Name, "ory_kratos_session") + require.NotEmpty(t, cookie.Value) + }) +} diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index 8d4a5e45986a..7fd946c593ae 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -177,23 +177,23 @@ func (s *Strategy) createRecoveryCodeForIdentity(w http.ResponseWriter, r *http. return } - flow, err := recovery.NewFlow(config, expiresIn, s.deps.GenerateCSRFToken(r), r, s, flow.TypeBrowser) + recoveryFlow, err := recovery.NewFlow(config, expiresIn, s.deps.GenerateCSRFToken(r), r, s, flow.TypeBrowser) if err != nil { s.deps.Writer().WriteError(w, r, err) return } - flow.DangerousSkipCSRFCheck = true - flow.State = recovery.StateEmailSent - flow.UI.Nodes = node.Nodes{} - flow.UI.Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). + recoveryFlow.DangerousSkipCSRFCheck = true + recoveryFlow.State = flow.StateEmailSent + recoveryFlow.UI.Nodes = node.Nodes{} + recoveryFlow.UI.Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). WithMetaLabel(text.NewInfoNodeLabelRecoveryCode()), ) - flow.UI.Nodes. + recoveryFlow.UI.Nodes. Append(node.NewInputField("method", s.RecoveryStrategyID(), node.CodeGroup, node.InputAttributeTypeSubmit). WithMetaLabel(text.NewInfoNodeLabelSubmit())) - if err := s.deps.RecoveryFlowPersister().CreateRecoveryFlow(ctx, flow); err != nil { + if err := s.deps.RecoveryFlowPersister().CreateRecoveryFlow(ctx, recoveryFlow); err != nil { s.deps.Writer().WriteError(w, r, err) return } @@ -213,7 +213,7 @@ func (s *Strategy) createRecoveryCodeForIdentity(w http.ResponseWriter, r *http. RawCode: rawCode, CodeType: RecoveryCodeTypeAdmin, ExpiresIn: expiresIn, - FlowID: flow.ID, + FlowID: recoveryFlow.ID, IdentityID: id.ID, }); err != nil { s.deps.Writer().WriteError(w, r, err) @@ -226,11 +226,11 @@ func (s *Strategy) createRecoveryCodeForIdentity(w http.ResponseWriter, r *http. Info("A recovery code has been created.") body := &recoveryCodeForIdentity{ - ExpiresAt: flow.ExpiresAt.UTC(), + ExpiresAt: recoveryFlow.ExpiresAt.UTC(), RecoveryLink: urlx.CopyWithQuery( s.deps.Config().SelfServiceFlowRecoveryUI(ctx), url.Values{ - "flow": {flow.ID.String()}, + "flow": {recoveryFlow.ID.String()}, }).String(), RecoveryCode: rawCode, } @@ -310,8 +310,8 @@ func (s *Strategy) Recover(w http.ResponseWriter, r *http.Request, f *recovery.F f.UI.ResetMessages() // If the email is present in the submission body, the user needs a new code via resend - if f.State != recovery.StateChooseMethod && len(body.Email) == 0 { - if err := flow.MethodEnabledAndAllowed(ctx, sID, sID, s.deps); err != nil { + if f.State != flow.StateChooseMethod && len(body.Email) == 0 { + if err := flow.MethodEnabledAndAllowed(ctx, flow.RecoveryFlow, sID, sID, s.deps); err != nil { return s.HandleRecoveryError(w, r, nil, body, err) } return s.recoveryUseCode(w, r, body, f) @@ -327,29 +327,29 @@ func (s *Strategy) Recover(w http.ResponseWriter, r *http.Request, f *recovery.F return errors.WithStack(flow.ErrCompletedByStrategy) } - if err := flow.MethodEnabledAndAllowed(ctx, sID, body.Method, s.deps); err != nil { + if err := flow.MethodEnabledAndAllowed(ctx, flow.RecoveryFlow, sID, body.Method, s.deps); err != nil { return s.HandleRecoveryError(w, r, nil, body, err) } - flow, err := s.deps.RecoveryFlowPersister().GetRecoveryFlow(ctx, x.ParseUUID(body.Flow)) + recoveryFlow, err := s.deps.RecoveryFlowPersister().GetRecoveryFlow(ctx, x.ParseUUID(body.Flow)) if err != nil { - return s.HandleRecoveryError(w, r, flow, body, err) + return s.HandleRecoveryError(w, r, recoveryFlow, body, err) } - if err := flow.Valid(); err != nil { - return s.HandleRecoveryError(w, r, flow, body, err) + if err := recoveryFlow.Valid(); err != nil { + return s.HandleRecoveryError(w, r, recoveryFlow, body, err) } - switch flow.State { - case recovery.StateChooseMethod: + switch recoveryFlow.State { + case flow.StateChooseMethod: fallthrough - case recovery.StateEmailSent: - return s.recoveryHandleFormSubmission(w, r, flow, body) - case recovery.StatePassedChallenge: + case flow.StateEmailSent: + return s.recoveryHandleFormSubmission(w, r, recoveryFlow, body) + case flow.StatePassedChallenge: // was already handled, do not allow retry - return s.retryRecoveryFlowWithMessage(w, r, flow.Type, text.NewErrorValidationRecoveryRetrySuccess()) + return s.retryRecoveryFlowWithMessage(w, r, recoveryFlow.Type, text.NewErrorValidationRecoveryRetrySuccess()) default: - return s.retryRecoveryFlowWithMessage(w, r, flow.Type, text.NewErrorValidationRecoveryStateFailure()) + return s.retryRecoveryFlowWithMessage(w, r, recoveryFlow.Type, text.NewErrorValidationRecoveryStateFailure()) } } @@ -357,7 +357,7 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, ctx := r.Context() f.UI.Messages.Clear() - f.State = recovery.StatePassedChallenge + f.State = flow.StatePassedChallenge f.SetCSRFToken(s.deps.CSRFHandler().RegenerateToken(w, r)) f.RecoveredIdentityID = uuid.NullUUID{ UUID: id.ID, @@ -540,7 +540,7 @@ func (s *Strategy) recoveryHandleFormSubmission(w http.ResponseWriter, r *http.R f.UI.SetCSRF(s.deps.GenerateCSRFToken(r)) f.Active = sqlxx.NullString(s.NodeGroup()) - f.State = recovery.StateEmailSent + f.State = flow.StateEmailSent f.UI.Messages.Set(text.NewRecoveryEmailWithCodeSent()) f.UI.Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). WithMetaLabel(text.NewInfoNodeLabelRecoveryCode()), diff --git a/selfservice/strategy/code/strategy_registration.go b/selfservice/strategy/code/strategy_registration.go new file mode 100644 index 000000000000..128ed7090258 --- /dev/null +++ b/selfservice/strategy/code/strategy_registration.go @@ -0,0 +1,260 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package code + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + + "github.com/gofrs/uuid" + "github.com/pkg/errors" + + "github.com/ory/kratos/continuity" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/schema" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/ui/container" + "github.com/ory/kratos/ui/node" + "github.com/ory/kratos/x" + "github.com/ory/x/urlx" +) + +var _ registration.Strategy = new(Strategy) + +// Update Registration Flow with Code Method +// +// swagger:model updateRegistrationFlowWithCodeMethod +type UpdateRegistrationFlowWithCodeMethod struct { + // The identity's traits + // + // required: true + Traits json.RawMessage `json:"traits,omitempty"` + + // The OTP Code sent to the user + // + // required: true + Code string `json:"code"` + + // The CSRF Token + CSRFToken string `json:"csrf_token"` + + // Method to use + // + // This field must be set to `code` when using the code method. + // + // required: true + Method string `json:"method"` + + // Transient data to pass along to any webhooks + // + // required: false + TransientPayload json.RawMessage `json:"transient_payload,omitempty"` +} + +type registrationCodeContainer struct { + Traits json.RawMessage `json:"traits"` + FlowID string `json:"flow_id"` + TransientPayload json.RawMessage `json:"transient_payload,omitempty"` +} + +func (s *Strategy) RegisterRegistrationRoutes(*x.RouterPublic) {} + +func (s *Strategy) HandleRegistrationError(w http.ResponseWriter, r *http.Request, flow *registration.Flow, body *UpdateRegistrationFlowWithCodeMethod, err error) error { + if flow != nil { + if body != nil { + action := flow.AppendTo(urlx.AppendPaths(s.deps.Config().SelfPublicURL(r.Context()), registration.RouteSubmitFlow)).String() + for _, n := range container.NewFromJSON(action, node.CodeGroup, body.Traits, "traits").Nodes { + // we only set the value and not the whole field because we want to keep types from the initial form generation + flow.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue()) + } + } + + flow.UI.SetCSRF(s.deps.GenerateCSRFToken(r)) + } + + return err +} + +func (s *Strategy) PopulateRegistrationMethod(r *http.Request, rf *registration.Flow) error { + ds, err := s.deps.Config().DefaultIdentityTraitsSchemaURL(r.Context()) + if err != nil { + return err + } + + nodes, err := container.NodesFromJSONSchema(r.Context(), node.CodeGroup, ds.String(), "", nil) + if err != nil { + return err + } + + for _, n := range nodes { + rf.UI.SetNode(n) + } + + return s.PopulateMethod(r, rf) +} + +type options func(*identity.Identity) error + +func WithCredentials(code string, usedAt sql.NullTime) options { + return func(i *identity.Identity) error { + return i.SetCredentialsWithConfig(identity.CredentialsTypeCodeAuth, identity.Credentials{Type: identity.CredentialsTypePassword, Identifiers: []string{}}, &identity.CredentialsCode{CodeHMAC: code, UsedAt: usedAt}) + } +} + +func (s *Strategy) handleIdentityTraits(ctx context.Context, f *registration.Flow, p *UpdateRegistrationFlowWithCodeMethod, i *identity.Identity, opts ...options) error { + f.TransientPayload = p.TransientPayload + if len(p.Traits) == 0 { + p.Traits = json.RawMessage("{}") + } + + // we explicitly set the Code credentials type + i.Traits = identity.Traits(p.Traits) + if err := i.SetCredentialsWithConfig(s.ID(), identity.Credentials{Type: s.ID(), Identifiers: []string{}}, &identity.CredentialsCode{CodeHMAC: "", UsedAt: sql.NullTime{}}); err != nil { + return err + } + + for _, opt := range opts { + if err := opt(i); err != nil { + return err + } + } + + // Validate the identity + if err := s.deps.IdentityValidator().Validate(ctx, i); err != nil { + return err + } + + return nil +} + +func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registration.Flow, i *identity.Identity) error { + if !s.deps.Config().SelfServiceCodeStrategy(r.Context()).RegistrationEnabled { + } + if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.deps); err != nil { + return err + } + + // Get the post payload and decode it. + // 1. Ensure there is a CSRF token in the payload + // 2. Ensure that the traits are valid + // 3. The identity validation will pre-emptively fail if the identity schema is invalid. e.g. no `code` identifier as a valid `email`. + // 4. Send out the code for the user to complete the registration flow + var p UpdateRegistrationFlowWithCodeMethod + if err := registration.DecodeBody(&p, r, s.dx, s.deps.Config(), registrationSchema); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + if err := flow.EnsureCSRF(s.deps, r, f.Type, s.deps.Config().DisableAPIFlowEnforcement(r.Context()), s.deps.GenerateCSRFToken, p.CSRFToken); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + var cntnr registrationCodeContainer + // continue with the previous registration flow? + if _, err := s.deps.ContinuityManager().Continue(r.Context(), w, r, continuitySessionName, continuity.WithPayload(&cntnr)); err == nil { + p.Traits = cntnr.Traits + p.TransientPayload = cntnr.TransientPayload + + if f.GetID() != uuid.Nil && f.GetID() != uuid.FromStringOrNil(cntnr.FlowID) { + return s.HandleRegistrationError(w, r, f, &p, errors.WithStack(errors.New("Continuity mismatch detected. The flow ID in the initial registration request does not match the flow ID from current registration request."))) + } + } + + codeManager := NewCodeStateManager(f) + + codeManager.RegisterCreateCodeState(func(ctx context.Context) error { + // Create the Registration code + + // Step 1: validate the identity's traits + if err := s.handleIdentityTraits(r.Context(), f, &p, i); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + // Store the flow in a continuity manager so that we can recover it later + if err := s.deps.ContinuityManager().Pause(ctx, w, r, continuitySessionName, continuity.WithPayload(®istrationCodeContainer{ + FlowID: f.ID.String(), + Traits: p.Traits, + TransientPayload: f.TransientPayload, + }), + continuity.WithLifespan(s.deps.Config().SelfServiceFlowRegistrationRequestLifespan(ctx))); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + // Step 2: Delete any previous registration codes for this flow ID + if err := s.deps.RegistrationCodePersister().DeleteRegistrationCodesOfFlow(ctx, f.ID); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + // Step 3: Get the identity email and send the code + cred, ok := i.GetCredentials(identity.CredentialsTypeCodeAuth) + if !ok { + return s.HandleRegistrationError(w, r, f, &p, errors.WithStack(schema.NewRequiredError("#/code", "code"))) + } else if len(cred.Identifiers) == 0 { + return s.HandleRegistrationError(w, r, f, &p, errors.WithStack(schema.NewMissingIdentifierError())) + } + + // kratos only supports email identifiers with the code method + // this is validated in the identity validation step above + if err := s.deps.CodeSender().SendRegistrationCode(ctx, f, i, cred.Identifiers...); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + // Step 4: Generate the UI for the `code` input form + // re-initialize the UI with a "clean" new state + // this should also provide a "resend" button and an option to change the email address + if err := s.NewCodeUINodes(r, f, p.Traits); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + // sets the flow state to code sent + s.NextFlowState(f) + + f.Active = identity.CredentialsTypeCodeAuth + if err := s.deps.RegistrationFlowPersister().UpdateRegistrationFlow(ctx, f); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + if x.IsJSONRequest(r) { + s.deps.Writer().Write(w, r, f) + } else { + http.Redirect(w, r, f.AppendTo(s.deps.Config().SelfServiceFlowRegistrationUI(ctx)).String(), http.StatusSeeOther) + } + + // we return an error to the flow handler so that it does not continue execution of the hooks. + // we are not done with the registration flow yet. The user needs to verify the code and then we need to persist the identity. + return errors.WithStack(flow.ErrCompletedByStrategy) + }) + + codeManager.RegisterVerifyCodeState(func(ctx context.Context) error { + // Validate the Registration Code + + // we are in the second submission state of the flow + // we need to check the code and update the identity + + // Step 1: Attempt to use the code + registrationCode, err := s.deps.RegistrationCodePersister().UseRegistrationCode(ctx, f.ID, p.Code) + if err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + // Step 2: The code was correct, populate the Identity credentials and traits + if err := s.handleIdentityTraits(ctx, f, &p, i, WithCredentials(registrationCode.CodeHMAC, registrationCode.UsedAt)); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + // since nothing has errored yet, we can assume that the code is correct + // and we can update the registration flow + s.NextFlowState(f) + + if err := s.deps.RegistrationFlowPersister().UpdateRegistrationFlow(ctx, f); err != nil { + return s.HandleRegistrationError(w, r, f, &p, err) + } + + return nil + }) + + return codeManager.Run(r.Context()) +} diff --git a/selfservice/strategy/code/strategy_registration_test.go b/selfservice/strategy/code/strategy_registration_test.go new file mode 100644 index 000000000000..74f1ccdd554f --- /dev/null +++ b/selfservice/strategy/code/strategy_registration_test.go @@ -0,0 +1,294 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package code_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + _ "embed" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow/registration" +) + +type state struct { + flowID string + csrfToken string + client *http.Client + email string +} + +func TestRegistrationCodeStrategyDisabled(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") + conf.MustSet(ctx, fmt.Sprintf("%s.%s.enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypePassword.String()), false) + conf.MustSet(ctx, fmt.Sprintf("%s.%s.enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth.String()), false) + conf.MustSet(ctx, fmt.Sprintf("%s.%s.registration_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth), false) + + _ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + + public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) + + client := testhelpers.NewClientWithCookies(t) + resp, err := client.Get(public.URL + registration.RouteInitBrowserFlow) + require.NoError(t, err) + require.EqualValues(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Falsef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.value==code)").Exists(), "%s", body) + + // attempt to still submit the code form even though it doesn't exist + + payload := strings.NewReader(url.Values{ + "csrf_token": {gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()}, + "method": {"code"}, + "traits.email": {testhelpers.RandomEmail()}, + }.Encode()) + req, err := http.NewRequestWithContext(ctx, "POST", public.URL+registration.RouteSubmitFlow+"?flow="+gjson.GetBytes(body, "id").String(), payload) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err = client.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "This endpoint was disabled by system administrator. Please check your url or contact the system administrator to enable it.", gjson.GetBytes(body, "error.reason").String()) +} + +func TestRegistrationCodeStrategy(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") + conf.MustSet(ctx, fmt.Sprintf("%s.%s.enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypePassword.String()), false) + conf.MustSet(ctx, fmt.Sprintf("%s.%s.enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth.String()), false) + conf.MustSet(ctx, fmt.Sprintf("%s.%s.registration_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth), true) + conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, "https://www.ory.sh") + conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{"https://www.ory.sh"}) + conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".code.hooks", []map[string]interface{}{ + {"hook": "session"}, + }) + + _ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + + public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) + + createRegistrationFlow := func(t *testing.T) *state { + t.Helper() + + client := testhelpers.NewClientWithCookies(t) + resp, err := client.Get(public.URL + registration.RouteInitBrowserFlow) + require.NoError(t, err) + require.EqualValues(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + flowID := gjson.GetBytes(body, "id").String() + require.NotEmpty(t, flowID) + + csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() + require.NotEmpty(t, csrfToken) + + require.Truef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.email)").Exists(), "%s", body) + require.Truef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.value==code)").Exists(), "%s", body) + + require.NoError(t, resp.Body.Close()) + return &state{ + csrfToken: csrfToken, + client: client, + flowID: flowID, + } + } + + type registerNewUserOptions struct { + onSubmitAssertions func(t *testing.T, s *state, resp *http.Response) + } + + type registerNewUserOption func(*registerNewUserOptions) + + withSubmitAssertions := func(f func(t *testing.T, s *state, resp *http.Response)) registerNewUserOption { + return func(o *registerNewUserOptions) { + o.onSubmitAssertions = f + } + } + + registerNewUser := func(t *testing.T, s *state, opts ...registerNewUserOption) *state { + o := new(registerNewUserOptions) + + for _, f := range opts { + f(o) + } + + email := testhelpers.RandomEmail() + + s.email = email + + payload := strings.NewReader(url.Values{ + "csrf_token": {s.csrfToken}, + "method": {"code"}, + "traits.email": {email}, + }.Encode()) + + req, err := http.NewRequestWithContext(ctx, "POST", public.URL+registration.RouteSubmitFlow+"?flow="+s.flowID, payload) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := s.client + + // 2. Submit Identifier (email) + resp, err := client.Do(req) + require.NoError(t, err) + if o.onSubmitAssertions != nil { + o.onSubmitAssertions(t, s, resp) + } else { + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() + require.NotEmptyf(t, csrfToken, "%s", body) + } + + // ory_kratos_continuity cookie is set to keep the state between the initial and the follow-up request + // since we cannot persist the identity until the code has been entered and verified, we keep the state + // within the cookie + var continuityCookie *http.Cookie + for _, c := range resp.Cookies() { + if strings.EqualFold(c.Name, "ory_kratos_continuity") { + continuityCookie = c + break + } + } + require.NotNil(t, continuityCookie) + require.NotEmpty(t, continuityCookie.Value) + require.NoError(t, resp.Body.Close()) + + return s + } + + submitOTP := func(t *testing.T, s *state, otp string, shouldHaveSessionCookie bool) *state { + req, err := http.NewRequestWithContext(ctx, "POST", public.URL+registration.RouteSubmitFlow+"?flow="+s.flowID, strings.NewReader(url.Values{ + "csrf_token": {s.csrfToken}, + "method": {"code"}, + "code": {otp}, + }.Encode())) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + // 3. Submit OTP + resp, err := s.client.Do(req) + require.NoError(t, err) + + verifiableAddress, err := reg.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, identity.VerifiableAddressTypeEmail, s.email) + require.NoError(t, err) + require.Equal(t, s.email, verifiableAddress.Value) + + id, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, verifiableAddress.IdentityID) + require.NoError(t, err) + require.NotNil(t, id.ID) + + _, ok := id.GetCredentials(identity.CredentialsTypeCodeAuth) + require.True(t, ok) + + if shouldHaveSessionCookie { + // we should now end up with a session cookie + var sessionCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "ory_kratos_session" { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie) + require.NotEmpty(t, sessionCookie.Value) + } + return s + } + + t.Run("case=should be able to register with code identity credentials", func(t *testing.T) { + + // 1. Initiate flow + state := createRegistrationFlow(t) + + // 2. Submit Identifier (email) + state = registerNewUser(t, state) + + message := testhelpers.CourierExpectMessage(t, reg, state.email, "Complete your account registration") + assert.Contains(t, message.Body, "please complete your account registration by entering the following code") + + registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + assert.NotEmpty(t, registrationCode) + + // 3. Submit OTP + state = submitOTP(t, state, registrationCode, true) + }) + + 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") + t.Cleanup(func() { + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") + }) + + // 1. Initiate flow + s := createRegistrationFlow(t) + + // 2. Submit Identifier (email) + s = registerNewUser(t, s, withSubmitAssertions(func(t *testing.T, s *state, resp *http.Response) { + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, gjson.GetBytes(body, "ui.messages").String(), "Could not find any login identifiers") + })) + + }) + + t.Run("case=should have verifiable address even if after session hook is disabled", func(t *testing.T) { + // disable the after session hook + conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".code.hooks", []map[string]interface{}{}) + + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".code.hooks", []map[string]interface{}{ + {"hook": "session"}, + }) + }) + + // 1. Initiate flow + state := createRegistrationFlow(t) + + // 2. Submit Identifier (email) + state = registerNewUser(t, state) + + message := testhelpers.CourierExpectMessage(t, reg, state.email, "Complete your account registration") + assert.Contains(t, message.Body, "please complete your account registration by entering the following code") + + registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + assert.NotEmpty(t, registrationCode) + + // 3. Submit OTP + state = submitOTP(t, state, registrationCode, false) + }) +} diff --git a/selfservice/strategy/code/strategy_verification.go b/selfservice/strategy/code/strategy_verification.go index 4de4bf9d16a5..aea07b9f6ae9 100644 --- a/selfservice/strategy/code/strategy_verification.go +++ b/selfservice/strategy/code/strategy_verification.go @@ -38,35 +38,7 @@ func (s *Strategy) RegisterAdminVerificationRoutes(admin *x.RouterAdmin) { // Otherwise, the default email input is added. // If the flow is a browser flow, the CSRF token is added to the UI. func (s *Strategy) PopulateVerificationMethod(r *http.Request, f *verification.Flow) error { - nodes := node.Nodes{} - switch f.State { - case verification.StateEmailSent: - nodes.Upsert( - node. - NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). - WithMetaLabel(text.NewInfoNodeLabelVerificationCode()), - ) - // Required for the re-send code button - nodes.Append( - node.NewInputField("method", s.NodeGroup(), node.CodeGroup, node.InputAttributeTypeHidden), - ) - f.UI.Messages.Set(text.NewVerificationEmailWithCodeSent()) - default: - nodes.Upsert( - node.NewInputField("email", nil, node.CodeGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute). - WithMetaLabel(text.NewInfoNodeInputEmail()), - ) - } - nodes.Append( - node.NewInputField("method", s.VerificationStrategyID(), node.CodeGroup, node.InputAttributeTypeSubmit). - WithMetaLabel(text.NewInfoNodeLabelSubmit()), - ) - - f.UI.Nodes = nodes - if f.Type == flow.TypeBrowser { - f.UI.SetCSRF(s.deps.GenerateCSRFToken(r)) - } - return nil + return s.PopulateMethod(r, f) } func (s *Strategy) decodeVerification(r *http.Request) (*updateVerificationFlowWithCodeMethod, error) { @@ -156,7 +128,7 @@ func (s *Strategy) Verify(w http.ResponseWriter, r *http.Request, f *verificatio return s.handleVerificationError(w, r, nil, body, err) } - if err := flow.MethodEnabledAndAllowed(r.Context(), s.VerificationStrategyID(), string(body.getMethod()), s.deps); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.VerificationStrategyID(), string(body.getMethod()), s.deps); err != nil { return s.handleVerificationError(w, r, f, body, err) } @@ -165,11 +137,11 @@ func (s *Strategy) Verify(w http.ResponseWriter, r *http.Request, f *verificatio } switch f.State { - case verification.StateChooseMethod: + case flow.StateChooseMethod: fallthrough - case verification.StateEmailSent: + case flow.StateEmailSent: return s.verificationHandleFormSubmission(w, r, f, body) - case verification.StatePassedChallenge: + case flow.StatePassedChallenge: return s.retryVerificationFlowWithMessage(w, r, f.Type, text.NewErrorValidationVerificationRetrySuccess()) default: return s.retryVerificationFlowWithMessage(w, r, f.Type, text.NewErrorValidationVerificationStateFailure()) @@ -177,7 +149,6 @@ func (s *Strategy) Verify(w http.ResponseWriter, r *http.Request, f *verificatio } func (s *Strategy) handleLinkClick(w http.ResponseWriter, r *http.Request, f *verification.Flow, code string) error { - // Pre-fill the code if codeField := f.UI.Nodes.Find("code"); codeField != nil { codeField.Attributes.SetValue(code) @@ -230,7 +201,7 @@ func (s *Strategy) verificationHandleFormSubmission(w http.ResponseWriter, r *ht // Continue execution } - f.State = verification.StateEmailSent + f.State = flow.StateEmailSent if err := s.PopulateVerificationMethod(r, f); err != nil { return s.handleVerificationError(w, r, f, body, err) @@ -294,7 +265,7 @@ func (s *Strategy) verificationUseCode(w http.ResponseWriter, r *http.Request, c Action: returnTo.String(), } - f.State = verification.StatePassedChallenge + f.State = flow.StatePassedChallenge // See https://github.com/ory/kratos/issues/1547 f.SetCSRFToken(flow.GetCSRFToken(s.deps, w, r, f.Type)) f.UI.Messages.Set(text.NewInfoSelfServiceVerificationSuccessful()) @@ -378,7 +349,6 @@ func (s *Strategy) retryVerificationFlowWithError(w http.ResponseWriter, r *http } func (s *Strategy) SendVerificationEmail(ctx context.Context, f *verification.Flow, i *identity.Identity, a *identity.VerifiableAddress) (err error) { - rawCode := GenerateCode() code, err := s.deps.VerificationCodePersister().CreateVerificationCode(ctx, &CreateVerificationCodeParams{ @@ -387,7 +357,6 @@ func (s *Strategy) SendVerificationEmail(ctx context.Context, f *verification.Fl VerifiableAddress: a, FlowID: f.ID, }) - if err != nil { return err } diff --git a/selfservice/strategy/code/strategy_verification_test.go b/selfservice/strategy/code/strategy_verification_test.go index b00bcda5c2bf..274d4058e9c1 100644 --- a/selfservice/strategy/code/strategy_verification_test.go +++ b/selfservice/strategy/code/strategy_verification_test.go @@ -43,7 +43,7 @@ func TestVerification(t *testing.T) { conf, reg := internal.NewFastRegistryWithMocks(t) initViper(t, ctx, conf) - var identityToVerify = &identity.Identity{ + identityToVerify := &identity.Identity{ ID: x.NewUUID(), Traits: identity.Traits(`{"email":"verifyme@ory.sh"}`), SchemaID: config.DefaultIdentityTraitsSchemaID, @@ -56,7 +56,7 @@ func TestVerification(t *testing.T) { }, } - var verificationEmail = gjson.GetBytes(identityToVerify.Traits, "email").String() + verificationEmail := gjson.GetBytes(identityToVerify.Traits, "email").String() _ = testhelpers.NewVerificationUIFlowEchoServer(t, reg) _ = testhelpers.NewLoginUIFlowEchoServer(t, reg) @@ -69,7 +69,7 @@ func TestVerification(t *testing.T) { require.NoError(t, reg.IdentityManager().Create(context.Background(), identityToVerify, identity.ManagerAllowWriteProtectedTraits)) - var expect = func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values), c int) string { + expect := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values), c int) string { if hc == nil { hc = testhelpers.NewDebugClient(t) if !isAPI { @@ -82,15 +82,15 @@ func TestVerification(t *testing.T) { testhelpers.ExpectURL(isAPI || isSPA, public.URL+verification.RouteSubmitFlow, conf.SelfServiceFlowVerificationUI(ctx).String())) } - var expectValidationError = func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { + expectValidationError := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { return expect(t, hc, isAPI, isSPA, values, testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK)) } - var expectSuccess = func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { + expectSuccess := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { return expect(t, hc, isAPI, isSPA, values, http.StatusOK) } - var submitVerificationCode = func(t *testing.T, body string, c *http.Client, code string) (string, *http.Response) { + submitVerificationCode := func(t *testing.T, body string, c *http.Client, code string) (string, *http.Response) { action := gjson.Get(body, "ui.action").String() require.NotEmpty(t, action, "%v", string(body)) csrfToken := extractCsrfToken([]byte(body)) @@ -135,14 +135,14 @@ func TestVerification(t *testing.T) { }) t.Run("description=should require an email to be sent", func(t *testing.T) { - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { assert.EqualValues(t, string(node.CodeGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, "Property email is missing.", gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(), "%s", actual) } - var values = func(v url.Values) { + values := func(v url.Values) { v.Del("email") } @@ -160,7 +160,7 @@ func TestVerification(t *testing.T) { }) t.Run("description=should require a valid email to be sent", func(t *testing.T) { - var check = func(t *testing.T, actual string, value string) { + check := func(t *testing.T, actual string, value string) { assert.EqualValues(t, string(node.CodeGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, fmt.Sprintf("%q is not valid \"email\"", value), gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(), @@ -168,7 +168,7 @@ func TestVerification(t *testing.T) { } for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} { - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", email) } @@ -194,7 +194,7 @@ func TestVerification(t *testing.T) { }) var email string - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { assert.EqualValues(t, string(node.CodeGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual) assertx.EqualAsJSON(t, text.NewVerificationEmailWithCodeSent(), json.RawMessage(gjson.Get(actual, "ui.messages.0").Raw)) @@ -203,7 +203,7 @@ func TestVerification(t *testing.T) { assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.") } - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", email) } @@ -295,7 +295,7 @@ func TestVerification(t *testing.T) { }) t.Run("description=should verify an email address", func(t *testing.T) { - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { assert.EqualValues(t, string(node.CodeGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, verificationEmail, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual) assertx.EqualAsJSON(t, text.NewVerificationEmailWithCodeSent(), json.RawMessage(gjson.Get(actual, "ui.messages.0").Raw)) @@ -335,7 +335,7 @@ func TestVerification(t *testing.T) { assert.True(t, time.Time(*address.VerifiedAt).Add(time.Second*5).After(time.Now())) } - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", verificationEmail) } @@ -353,8 +353,7 @@ func TestVerification(t *testing.T) { }) t.Run("description=should verify an email address when the link is opened in another browser", func(t *testing.T) { - - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", verificationEmail) } @@ -377,7 +376,7 @@ func TestVerification(t *testing.T) { newValidFlow := func(t *testing.T, fType flow.Type, requestURL string) (*verification.Flow, *code.VerificationCode, string) { f, err := verification.NewFlow(conf, time.Hour, x.FakeCSRFToken, httptest.NewRequest("GET", requestURL, nil), code.NewStrategy(reg), fType) require.NoError(t, err) - f.State = verification.StateEmailSent + f.State = flow.StateEmailSent require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(context.Background(), f)) email := identity.NewVerifiableEmailAddress(verificationEmail, identityToVerify.ID) identityToVerify.VerifiableAddresses = append(identityToVerify.VerifiableAddresses, *email) @@ -459,7 +458,7 @@ func TestVerification(t *testing.T) { assert.Equal(t, text.ErrIDSelfServiceFlowReplaced, gjson.GetBytes(f2, "error.id").String()) }) - var resendVerificationCode = func(t *testing.T, client *http.Client, flow string, flowType string, statusCode int) string { + resendVerificationCode := func(t *testing.T, client *http.Client, flow string, flowType string, statusCode int) string { action := gjson.Get(flow, "ui.action").String() assert.NotEmpty(t, action) @@ -503,7 +502,6 @@ func TestVerification(t *testing.T) { }) t.Run("case=should not be able to use first code after resending code", func(t *testing.T) { - body := expectSuccess(t, nil, true, false, func(v url.Values) { v.Set("email", verificationEmail) }) @@ -636,5 +634,4 @@ func TestVerification(t *testing.T) { }) } }) - } diff --git a/selfservice/strategy/code/stub/code.identity.schema.json b/selfservice/strategy/code/stub/code.identity.schema.json new file mode 100644 index 000000000000..3a3c6ff98172 --- /dev/null +++ b/selfservice/strategy/code/stub/code.identity.schema.json @@ -0,0 +1,26 @@ +{ + "$id": "https://example.com/person.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "ory.sh/kratos": { + "credentials": { + "code": { + "identifier": true + } + }, + "verification": { + "via": "email" + } + } + } + } + } + } +} diff --git a/selfservice/strategy/link/strategy.go b/selfservice/strategy/link/strategy.go index fa5e9218a1df..da66e1816bf5 100644 --- a/selfservice/strategy/link/strategy.go +++ b/selfservice/strategy/link/strategy.go @@ -19,13 +19,17 @@ import ( "github.com/ory/x/decoderx" ) -var _ recovery.Strategy = new(Strategy) -var _ recovery.AdminHandler = new(Strategy) -var _ recovery.PublicHandler = new(Strategy) +var ( + _ recovery.Strategy = new(Strategy) + _ recovery.AdminHandler = new(Strategy) + _ recovery.PublicHandler = new(Strategy) +) -var _ verification.Strategy = new(Strategy) -var _ verification.AdminHandler = new(Strategy) -var _ verification.PublicHandler = new(Strategy) +var ( + _ verification.Strategy = new(Strategy) + _ verification.AdminHandler = new(Strategy) + _ verification.PublicHandler = new(Strategy) +) type ( // FlowMethod contains the configuration for this selfservice strategy. @@ -83,10 +87,6 @@ func NewStrategy(d strategyDependencies) *Strategy { return &Strategy{d: d, dx: decoderx.NewHTTP()} } -func (s *Strategy) RecoveryNodeGroup() node.UiNodeGroup { - return node.LinkGroup -} - -func (s *Strategy) VerificationNodeGroup() node.UiNodeGroup { +func (s *Strategy) NodeGroup() node.UiNodeGroup { return node.LinkGroup } diff --git a/selfservice/strategy/link/strategy_recovery.go b/selfservice/strategy/link/strategy_recovery.go index 799b10422c89..40d21eb89764 100644 --- a/selfservice/strategy/link/strategy_recovery.go +++ b/selfservice/strategy/link/strategy_recovery.go @@ -40,7 +40,6 @@ func (s *Strategy) RecoveryStrategyID() string { func (s *Strategy) RegisterPublicRecoveryRoutes(public *x.RouterPublic) { s.d.CSRFHandler().IgnorePath(RouteAdminCreateRecoveryLink) public.POST(RouteAdminCreateRecoveryLink, x.RedirectToAdminRoute(s.d)) - } func (s *Strategy) RegisterAdminRecoveryRoutes(admin *x.RouterAdmin) { @@ -198,7 +197,8 @@ func (s *Strategy) createRecoveryLinkForIdentity(w http.ResponseWriter, r *http. url.Values{ "token": {token.Token}, "flow": {req.ID.String()}, - }).String()}, + }).String(), + }, herodot.UnescapedHTML) } @@ -237,7 +237,7 @@ func (s *Strategy) Recover(w http.ResponseWriter, r *http.Request, f *recovery.F } if len(body.Token) > 0 { - if err := flow.MethodEnabledAndAllowed(r.Context(), s.RecoveryStrategyID(), s.RecoveryStrategyID(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.RecoveryStrategyID(), s.RecoveryStrategyID(), s.d); err != nil { return s.HandleRecoveryError(w, r, nil, body, err) } @@ -253,7 +253,7 @@ func (s *Strategy) Recover(w http.ResponseWriter, r *http.Request, f *recovery.F return errors.WithStack(flow.ErrCompletedByStrategy) } - if err := flow.MethodEnabledAndAllowed(r.Context(), s.RecoveryStrategyID(), body.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.RecoveryStrategyID(), body.Method, s.d); err != nil { return s.HandleRecoveryError(w, r, nil, body, err) } @@ -267,11 +267,11 @@ func (s *Strategy) Recover(w http.ResponseWriter, r *http.Request, f *recovery.F } switch req.State { - case recovery.StateChooseMethod: + case flow.StateChooseMethod: fallthrough - case recovery.StateEmailSent: + case flow.StateEmailSent: return s.recoveryHandleFormSubmission(w, r, req) - case recovery.StatePassedChallenge: + case flow.StatePassedChallenge: // was already handled, do not allow retry return s.retryRecoveryFlowWithMessage(w, r, req.Type, text.NewErrorValidationRecoveryRetrySuccess()) default: @@ -281,7 +281,7 @@ func (s *Strategy) Recover(w http.ResponseWriter, r *http.Request, f *recovery.F func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, f *recovery.Flow, id *identity.Identity) error { f.UI.Messages.Clear() - f.State = recovery.StatePassedChallenge + f.State = flow.StatePassedChallenge f.SetCSRFToken(s.d.CSRFHandler().RegenerateToken(w, r)) f.RecoveredIdentityID = uuid.NullUUID{ UUID: id.ID, @@ -455,8 +455,8 @@ func (s *Strategy) recoveryHandleFormSubmission(w http.ResponseWriter, r *http.R node.NewInputField("email", body.Email, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()), ) - f.Active = sqlxx.NullString(s.RecoveryNodeGroup()) - f.State = recovery.StateEmailSent + f.Active = sqlxx.NullString(s.NodeGroup()) + f.State = flow.StateEmailSent f.UI.Messages.Set(text.NewRecoveryEmailSent()) if err := s.d.RecoveryFlowPersister().UpdateRecoveryFlow(r.Context(), f); err != nil { return s.HandleRecoveryError(w, r, f, body, err) diff --git a/selfservice/strategy/link/strategy_recovery_test.go b/selfservice/strategy/link/strategy_recovery_test.go index 6dee51cf9207..d3b7ff5753b3 100644 --- a/selfservice/strategy/link/strategy_recovery_test.go +++ b/selfservice/strategy/link/strategy_recovery_test.go @@ -61,9 +61,10 @@ func init() { } func createIdentityToRecover(t *testing.T, reg *driver.RegistryDefault, email string) *identity.Identity { - var id = &identity.Identity{ + id := &identity.Identity{ Credentials: map[identity.CredentialsType]identity.Credentials{ - "password": {Type: "password", Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"foo"}`)}}, + "password": {Type: "password", Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"foo"}`)}, + }, Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, email)), SchemaID: config.DefaultIdentityTraitsSchemaID, } @@ -273,7 +274,7 @@ func TestRecovery(t *testing.T) { public, _, publicRouter, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) - var expect = func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values), c int) string { + expect := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values), c int) string { if hc == nil { hc = testhelpers.NewDebugClient(t) if !isAPI { @@ -286,11 +287,11 @@ func TestRecovery(t *testing.T) { testhelpers.ExpectURL(isAPI || isSPA, public.URL+recovery.RouteSubmitFlow, conf.SelfServiceFlowRecoveryUI(ctx).String())) } - var expectValidationError = func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { + expectValidationError := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { return expect(t, hc, isAPI, isSPA, values, testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK)) } - var expectSuccess = func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { + expectSuccess := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { return expect(t, hc, isAPI, isSPA, values, http.StatusOK) } @@ -311,14 +312,14 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should require an email to be sent", func(t *testing.T) { - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { assert.EqualValues(t, node.LinkGroup, gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, "Property email is missing.", gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(), "%s", actual) } - var values = func(v url.Values) { + values := func(v url.Values) { v.Del("email") } @@ -336,14 +337,14 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should require a valid email to be sent", func(t *testing.T) { - var check = func(t *testing.T, actual string, value string) { + check := func(t *testing.T, actual string, value string) { assert.EqualValues(t, node.LinkGroup, gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, fmt.Sprintf("%q is not valid \"email\"", value), gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(), "%s", actual) } for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} { - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", email) } @@ -422,7 +423,7 @@ func TestRecovery(t *testing.T) { conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, false) }) var email string - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { assert.EqualValues(t, node.LinkGroup, gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual) assertx.EqualAsJSON(t, text.NewRecoveryEmailSent(), json.RawMessage(gjson.Get(actual, "ui.messages.0").Raw)) @@ -431,7 +432,7 @@ func TestRecovery(t *testing.T) { assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.") } - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", email) } @@ -452,7 +453,7 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should not be able to recover an inactive account", func(t *testing.T) { - var check = func(t *testing.T, recoverySubmissionResponse, recoveryEmail string, isAPI bool) { + check := func(t *testing.T, recoverySubmissionResponse, recoveryEmail string, isAPI bool) { addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) assert.NoError(t, err) @@ -503,7 +504,7 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should recover an account", func(t *testing.T) { - var check = func(t *testing.T, recoverySubmissionResponse, recoveryEmail, returnTo string) { + check := func(t *testing.T, recoverySubmissionResponse, recoveryEmail, returnTo string) { addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) assert.NoError(t, err) assert.False(t, addr.Verified) @@ -634,7 +635,7 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should recover an account and set the csrf cookies", func(t *testing.T) { - var check = func(t *testing.T, actual, recoveryEmail string, cl *http.Client, do func(*http.Client, *http.Request) (*http.Response, error)) { + check := func(t *testing.T, actual, recoveryEmail string, cl *http.Client, do func(*http.Client, *http.Request) (*http.Response, error)) { message := testhelpers.CourierExpectMessage(t, reg, recoveryEmail, "Recover access to your account") recoveryLink := testhelpers.CourierExpectLinkInMessage(t, message, 1) @@ -659,21 +660,21 @@ func TestRecovery(t *testing.T) { body := x.MustReadAll(actualRes.Body) require.NoError(t, actualRes.Body.Close()) assert.Equal(t, http.StatusOK, actualRes.StatusCode, "%s", body) - assert.Equal(t, string(recovery.StatePassedChallenge), gjson.GetBytes(body, "state").String(), "%s", body) + assert.Equal(t, string(flow.StatePassedChallenge), gjson.GetBytes(body, "state").String(), "%s", body) } email := x.NewUUID().String() + "@ory.sh" id := createIdentityToRecover(t, reg, email) t.Run("case=unauthenticated", func(t *testing.T) { - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", email) } check(t, expectSuccess(t, nil, false, false, values), email, testhelpers.NewClientWithCookies(t), (*http.Client).Do) }) t.Run("case=already logged into another account", func(t *testing.T) { - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", email) } @@ -684,7 +685,7 @@ func TestRecovery(t *testing.T) { }) t.Run("case=already logged into the account", func(t *testing.T) { - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", email) } @@ -715,7 +716,7 @@ func TestRecovery(t *testing.T) { require.NoError(t, err) assert.True(t, actualSession.IsActive()) - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { message := testhelpers.CourierExpectMessage(t, reg, recoveryEmail, "Recover access to your account") recoveryLink := testhelpers.CourierExpectLinkInMessage(t, message, 1) @@ -736,7 +737,7 @@ func TestRecovery(t *testing.T) { assert.False(t, actualSession.IsActive()) } - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", recoveryEmail) } diff --git a/selfservice/strategy/link/strategy_verification.go b/selfservice/strategy/link/strategy_verification.go index e09ffb39f603..804219531d8c 100644 --- a/selfservice/strategy/link/strategy_verification.go +++ b/selfservice/strategy/link/strategy_verification.go @@ -122,14 +122,14 @@ func (s *Strategy) Verify(w http.ResponseWriter, r *http.Request, f *verificatio } if len(body.Token) > 0 { - if err := flow.MethodEnabledAndAllowed(r.Context(), s.VerificationStrategyID(), s.VerificationStrategyID(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.VerificationStrategyID(), s.VerificationStrategyID(), s.d); err != nil { return s.handleVerificationError(w, r, nil, body, err) } return s.verificationUseToken(w, r, body, f) } - if err := flow.MethodEnabledAndAllowed(r.Context(), s.VerificationStrategyID(), body.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.VerificationStrategyID(), body.Method, s.d); err != nil { return s.handleVerificationError(w, r, f, body, err) } @@ -138,12 +138,12 @@ func (s *Strategy) Verify(w http.ResponseWriter, r *http.Request, f *verificatio } switch f.State { - case verification.StateChooseMethod: + case flow.StateChooseMethod: fallthrough - case verification.StateEmailSent: + case flow.StateEmailSent: // Do nothing (continue with execution after this switch statement) return s.verificationHandleFormSubmission(w, r, f) - case verification.StatePassedChallenge: + case flow.StatePassedChallenge: return s.retryVerificationFlowWithMessage(w, r, f.Type, text.NewErrorValidationVerificationRetrySuccess()) default: return s.retryVerificationFlowWithMessage(w, r, f.Type, text.NewErrorValidationVerificationStateFailure()) @@ -151,7 +151,7 @@ func (s *Strategy) Verify(w http.ResponseWriter, r *http.Request, f *verificatio } func (s *Strategy) verificationHandleFormSubmission(w http.ResponseWriter, r *http.Request, f *verification.Flow) error { - var body = new(verificationSubmitPayload) + body := new(verificationSubmitPayload) body, err := s.decodeVerification(r) if err != nil { return s.handleVerificationError(w, r, f, body, err) @@ -178,8 +178,8 @@ func (s *Strategy) verificationHandleFormSubmission(w http.ResponseWriter, r *ht node.NewInputField("email", body.Email, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()), ) - f.Active = sqlxx.NullString(s.VerificationNodeGroup()) - f.State = verification.StateEmailSent + f.Active = sqlxx.NullString(s.NodeGroup()) + f.State = flow.StateEmailSent f.UI.Messages.Set(text.NewVerificationEmailSent()) if err := s.d.VerificationFlowPersister().UpdateVerificationFlow(r.Context(), f); err != nil { return s.handleVerificationError(w, r, f, body, err) @@ -232,7 +232,7 @@ func (s *Strategy) verificationUseToken(w http.ResponseWriter, r *http.Request, Action: returnTo.String(), } f.UI.Messages.Clear() - f.State = verification.StatePassedChallenge + f.State = flow.StatePassedChallenge // See https://github.com/ory/kratos/issues/1547 f.SetCSRFToken(flow.GetCSRFToken(s.d, w, r, f.Type)) f.UI.Messages.Set(text.NewInfoSelfServiceVerificationSuccessful()) @@ -304,7 +304,6 @@ func (s *Strategy) retryVerificationFlowWithError(w http.ResponseWriter, r *http } func (s *Strategy) SendVerificationEmail(ctx context.Context, f *verification.Flow, i *identity.Identity, a *identity.VerifiableAddress) error { - token := NewSelfServiceVerificationToken(a, f, s.d.Config().SelfServiceLinkMethodLifespan(ctx)) if err := s.d.VerificationTokenPersister().CreateVerificationToken(ctx, token); err != nil { return err diff --git a/selfservice/strategy/link/strategy_verification_test.go b/selfservice/strategy/link/strategy_verification_test.go index 474107292f2f..bfd27848a877 100644 --- a/selfservice/strategy/link/strategy_verification_test.go +++ b/selfservice/strategy/link/strategy_verification_test.go @@ -41,15 +41,16 @@ func TestVerification(t *testing.T) { conf, reg := internal.NewFastRegistryWithMocks(t) initViper(t, conf) - var identityToVerify = &identity.Identity{ + identityToVerify := &identity.Identity{ ID: x.NewUUID(), Traits: identity.Traits(`{"email":"verifyme@ory.sh"}`), SchemaID: config.DefaultIdentityTraitsSchemaID, Credentials: map[identity.CredentialsType]identity.Credentials{ - "password": {Type: "password", Identifiers: []string{"recoverme@ory.sh"}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"foo"}`)}}, + "password": {Type: "password", Identifiers: []string{"recoverme@ory.sh"}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"foo"}`)}, + }, } - var verificationEmail = gjson.GetBytes(identityToVerify.Traits, "email").String() + verificationEmail := gjson.GetBytes(identityToVerify.Traits, "email").String() _ = testhelpers.NewVerificationUIFlowEchoServer(t, reg) _ = testhelpers.NewLoginUIFlowEchoServer(t, reg) @@ -62,7 +63,7 @@ func TestVerification(t *testing.T) { require.NoError(t, reg.IdentityManager().Create(context.Background(), identityToVerify, identity.ManagerAllowWriteProtectedTraits)) - var expect = func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values), c int) string { + expect := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values), c int) string { if hc == nil { hc = testhelpers.NewDebugClient(t) if !isAPI { @@ -75,11 +76,11 @@ func TestVerification(t *testing.T) { testhelpers.ExpectURL(isAPI || isSPA, public.URL+verification.RouteSubmitFlow, conf.SelfServiceFlowVerificationUI(ctx).String())) } - var expectValidationError = func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { + expectValidationError := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { return expect(t, hc, isAPI, isSPA, values, testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK)) } - var expectSuccess = func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { + expectSuccess := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string { return expect(t, hc, isAPI, isSPA, values, http.StatusOK) } @@ -114,14 +115,14 @@ func TestVerification(t *testing.T) { }) t.Run("description=should require an email to be sent", func(t *testing.T) { - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, "Property email is missing.", gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(), "%s", actual) } - var values = func(v url.Values) { + values := func(v url.Values) { v.Del("email") } @@ -139,7 +140,7 @@ func TestVerification(t *testing.T) { }) t.Run("description=should require a valid email to be sent", func(t *testing.T) { - var check = func(t *testing.T, actual string, value string) { + check := func(t *testing.T, actual string, value string) { assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, fmt.Sprintf("%q is not valid \"email\"", value), gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(), @@ -147,7 +148,7 @@ func TestVerification(t *testing.T) { } for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} { - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", email) } @@ -172,7 +173,7 @@ func TestVerification(t *testing.T) { conf.Set(ctx, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, false) }) var email string - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual) assertx.EqualAsJSON(t, text.NewVerificationEmailSent(), json.RawMessage(gjson.Get(actual, "ui.messages.0").Raw)) @@ -181,7 +182,7 @@ func TestVerification(t *testing.T) { assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.") } - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", email) } @@ -252,7 +253,7 @@ func TestVerification(t *testing.T) { time.Sleep(time.Millisecond * 201) - //Clear cookies as link might be opened in another browser + // Clear cookies as link might be opened in another browser c = testhelpers.NewClientWithCookies(t) res, err := c.Get(verificationLink) require.NoError(t, err) @@ -269,7 +270,7 @@ func TestVerification(t *testing.T) { }) t.Run("description=should verify an email address", func(t *testing.T) { - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, verificationEmail, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual) assertx.EqualAsJSON(t, text.NewVerificationEmailSent(), json.RawMessage(gjson.Get(actual, "ui.messages.0").Raw)) @@ -304,7 +305,7 @@ func TestVerification(t *testing.T) { assert.True(t, time.Time(*address.VerifiedAt).Add(time.Second*5).After(time.Now())) } - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", verificationEmail) } @@ -322,7 +323,7 @@ func TestVerification(t *testing.T) { }) t.Run("description=should verify an email address when the link is opened in another browser", func(t *testing.T) { - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { message := testhelpers.CourierExpectMessage(t, reg, verificationEmail, "Please verify your email address") verificationLink := testhelpers.CourierExpectLinkInMessage(t, message, 1) @@ -344,7 +345,7 @@ func TestVerification(t *testing.T) { assert.EqualValues(t, "passed_challenge", gjson.Get(actualBody, "state").String()) } - var values = func(v url.Values) { + values := func(v url.Values) { v.Set("email", verificationEmail) } @@ -354,7 +355,7 @@ func TestVerification(t *testing.T) { newValidFlow := func(t *testing.T, fType flow.Type, requestURL string) (*verification.Flow, *link.VerificationToken) { f, err := verification.NewFlow(conf, time.Hour, x.FakeCSRFToken, httptest.NewRequest("GET", requestURL, nil), nil, fType) require.NoError(t, err) - f.State = verification.StateEmailSent + f.State = flow.StateEmailSent require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(context.Background(), f)) email := identity.NewVerifiableEmailAddress(verificationEmail, identityToVerify.ID) identityToVerify.VerifiableAddresses = append(identityToVerify.VerifiableAddresses, *email) @@ -412,7 +413,6 @@ func TestVerification(t *testing.T) { }) t.Run("case=should not be able to use code from different flow", func(t *testing.T) { - f1, _ := newValidBrowserFlow(t, public.URL+verification.RouteInitBrowserFlow) _, t2 := newValidBrowserFlow(t, public.URL+verification.RouteInitBrowserFlow) diff --git a/selfservice/strategy/lookup/login.go b/selfservice/strategy/lookup/login.go index b5e5fddda7e8..75edc2181059 100644 --- a/selfservice/strategy/lookup/login.go +++ b/selfservice/strategy/lookup/login.go @@ -94,7 +94,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, err } - if err := flow.MethodEnabledAndAllowedFromRequest(r, s.ID().String(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil { return nil, err } diff --git a/selfservice/strategy/lookup/settings.go b/selfservice/strategy/lookup/settings.go index 261336ecdcbc..6f61966e353c 100644 --- a/selfservice/strategy/lookup/settings.go +++ b/selfservice/strategy/lookup/settings.go @@ -108,7 +108,7 @@ func (s *Strategy) Settings(w http.ResponseWriter, r *http.Request, f *settings. if p.RegenerateLookup || p.RevealLookup || p.ConfirmLookup || p.DisableLookup { // This method has only two submit buttons p.Method = s.SettingsStrategyID() - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), p.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil { return nil, s.handleSettingsError(w, r, ctxUpdate, &p, err) } } else { @@ -141,7 +141,7 @@ func (s *Strategy) continueSettingsFlow( ctxUpdate *settings.UpdateContext, p *updateSettingsFlowWithLookupMethod, ) error { if p.ConfirmLookup || p.RevealLookup || p.RegenerateLookup || p.DisableLookup { - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), flow.SettingsFlow, s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil { return err } diff --git a/selfservice/strategy/lookup/settings_test.go b/selfservice/strategy/lookup/settings_test.go index 92a81e964971..fce2be4c0974 100644 --- a/selfservice/strategy/lookup/settings_test.go +++ b/selfservice/strategy/lookup/settings_test.go @@ -273,7 +273,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("type=can not confirm without regenerate", func(t *testing.T) { id, codes := createIdentity(t, reg) - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set(node.LookupConfirm, "true") } @@ -310,7 +310,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("type=regenerate but no confirmation", func(t *testing.T) { id, codes := createIdentity(t, reg) - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set(node.LookupRegenerate, "true") } @@ -363,13 +363,13 @@ func TestCompleteSettings(t *testing.T) { }, } { t.Run("credentials="+tc.d, func(t *testing.T) { - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Del(node.LookupReveal) v.Del(node.LookupDisable) v.Set(node.LookupRegenerate, "true") } - var payloadConfirm = func(v url.Values) { + payloadConfirm := func(v url.Values) { v.Del(node.LookupRegenerate) v.Del(node.LookupDisable) v.Del(node.LookupReveal) @@ -401,7 +401,7 @@ func TestCompleteSettings(t *testing.T) { assert.Equal(t, http.StatusOK, res.StatusCode) assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) - assert.EqualValues(t, settings.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) + assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) checkIdentity(t, id, f) testhelpers.EnsureAAL(t, apiClient, publicTS, "aal2", string(identity.CredentialsTypeLookup)) @@ -427,7 +427,7 @@ func TestCompleteSettings(t *testing.T) { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } - assert.EqualValues(t, settings.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) + assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) checkIdentity(t, id, f) testhelpers.EnsureAAL(t, browserClient, publicTS, "aal2", string(identity.CredentialsTypeLookup)) } @@ -463,7 +463,7 @@ func TestCompleteSettings(t *testing.T) { }, } { t.Run("credentials="+tc.d, func(t *testing.T) { - var payloadConfirm = func(v url.Values) { + payloadConfirm := func(v url.Values) { v.Del(node.LookupRegenerate) v.Del(node.LookupReveal) v.Set(node.LookupDisable, "true") @@ -489,7 +489,7 @@ func TestCompleteSettings(t *testing.T) { assert.Equal(t, http.StatusOK, res.StatusCode) assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) - assert.EqualValues(t, settings.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) + assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) checkIdentity(t, id, f) testhelpers.EnsureAAL(t, apiClient, publicTS, "aal1") @@ -512,7 +512,7 @@ func TestCompleteSettings(t *testing.T) { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } - assert.EqualValues(t, settings.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) + assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) checkIdentity(t, id, f) testhelpers.EnsureAAL(t, browserClient, publicTS, "aal1") } diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index aa296df7e98d..b25cc0a050da 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -142,12 +142,15 @@ func generateState(flowID string) *State { Data: x.NewUUID().Bytes(), } } + func (s *State) setCode(code string) { s.Data = sha512.New().Sum([]byte(code)) } + func (s *State) codeMatches(code string) bool { return bytes.Equal(s.Data, sha512.New().Sum([]byte(code))) } + func parseState(s string) (*State, error) { raw, err := base64.RawURLEncoding.DecodeString(s) if err != nil { diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index edf3faeb80ae..da21342033bc 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -159,12 +159,12 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, s.handleError(w, r, f, "", nil, errors.WithStack(herodot.ErrBadRequest.WithDebug(err.Error()).WithReasonf("Unable to parse HTTP form request: %s", err.Error()))) } - var pid = p.Provider // this can come from both url query and post body + pid := p.Provider // this can come from both url query and post body if pid == "" { return nil, errors.WithStack(flow.ErrStrategyNotResponsible) } - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil { return nil, s.handleError(w, r, f, pid, nil, err) } diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go index 1e303c50df35..690d4650fa3a 100644 --- a/selfservice/strategy/oidc/strategy_registration.go +++ b/selfservice/strategy/oidc/strategy_registration.go @@ -127,12 +127,12 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat f.TransientPayload = p.TransientPayload - var pid = p.Provider // this can come from both url query and post body + pid := p.Provider // this can come from both url query and post body if pid == "" { return errors.WithStack(flow.ErrStrategyNotResponsible) } - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil { return s.handleError(w, r, f, pid, nil, err) } diff --git a/selfservice/strategy/oidc/strategy_settings_test.go b/selfservice/strategy/oidc/strategy_settings_test.go index d7497e567ab3..69f2bc03a560 100644 --- a/selfservice/strategy/oidc/strategy_settings_test.go +++ b/selfservice/strategy/oidc/strategy_settings_test.go @@ -76,41 +76,59 @@ func TestSettingsStrategy(t *testing.T) { // Make test data for this test run unique testID := x.NewUUID().String() users := map[string]*identity.Identity{ - "password": {ID: x.NewUUID(), Traits: identity.Traits(`{"email":"john` + testID + `@doe.com"}`), + "password": { + ID: x.NewUUID(), Traits: identity.Traits(`{"email":"john` + testID + `@doe.com"}`), SchemaID: config.DefaultIdentityTraitsSchemaID, Credentials: map[identity.CredentialsType]identity.Credentials{ - "password": {Type: "password", + "password": { + Type: "password", Identifiers: []string{"john+" + testID + "@doe.com"}, - Config: sqlxx.JSONRawMessage(`{"hashed_password":"$argon2id$iammocked...."}`)}}, + Config: sqlxx.JSONRawMessage(`{"hashed_password":"$argon2id$iammocked...."}`), + }, + }, }, - "oryer": {ID: x.NewUUID(), Traits: identity.Traits(`{"email":"hackerman+` + testID + `@ory.sh"}`), + "oryer": { + ID: x.NewUUID(), Traits: identity.Traits(`{"email":"hackerman+` + testID + `@ory.sh"}`), SchemaID: config.DefaultIdentityTraitsSchemaID, Credentials: map[identity.CredentialsType]identity.Credentials{ - identity.CredentialsTypeOIDC: {Type: identity.CredentialsTypeOIDC, + identity.CredentialsTypeOIDC: { + Type: identity.CredentialsTypeOIDC, Identifiers: []string{"ory:hackerman+" + testID}, - Config: sqlxx.JSONRawMessage(`{"providers":[{"provider":"ory","subject":"hackerman+` + testID + `"}]}`)}}, + Config: sqlxx.JSONRawMessage(`{"providers":[{"provider":"ory","subject":"hackerman+` + testID + `"}]}`), + }, + }, }, - "githuber": {ID: x.NewUUID(), Traits: identity.Traits(`{"email":"hackerman+github+` + testID + `@ory.sh"}`), + "githuber": { + ID: x.NewUUID(), Traits: identity.Traits(`{"email":"hackerman+github+` + testID + `@ory.sh"}`), Credentials: map[identity.CredentialsType]identity.Credentials{ - identity.CredentialsTypeOIDC: {Type: identity.CredentialsTypeOIDC, + identity.CredentialsTypeOIDC: { + Type: identity.CredentialsTypeOIDC, Identifiers: []string{"ory:hackerman+github+" + testID, "github:hackerman+github+" + testID}, - Config: sqlxx.JSONRawMessage(`{"providers":[{"provider":"ory","subject":"hackerman+github+` + testID + `"},{"provider":"github","subject":"hackerman+github+` + testID + `"}]}`)}}, + Config: sqlxx.JSONRawMessage(`{"providers":[{"provider":"ory","subject":"hackerman+github+` + testID + `"},{"provider":"github","subject":"hackerman+github+` + testID + `"}]}`), + }, + }, SchemaID: config.DefaultIdentityTraitsSchemaID, }, - "multiuser": {ID: x.NewUUID(), Traits: identity.Traits(`{"email":"hackerman+multiuser+` + testID + `@ory.sh"}`), + "multiuser": { + ID: x.NewUUID(), Traits: identity.Traits(`{"email":"hackerman+multiuser+` + testID + `@ory.sh"}`), Credentials: map[identity.CredentialsType]identity.Credentials{ - "password": {Type: "password", + "password": { + Type: "password", Identifiers: []string{"hackerman+multiuser+" + testID + "@ory.sh"}, - Config: sqlxx.JSONRawMessage(`{"hashed_password":"$argon2id$iammocked...."}`)}, - identity.CredentialsTypeOIDC: {Type: identity.CredentialsTypeOIDC, + Config: sqlxx.JSONRawMessage(`{"hashed_password":"$argon2id$iammocked...."}`), + }, + identity.CredentialsTypeOIDC: { + Type: identity.CredentialsTypeOIDC, Identifiers: []string{"ory:hackerman+multiuser+" + testID, "google:hackerman+multiuser+" + testID}, - Config: sqlxx.JSONRawMessage(`{"providers":[{"provider":"ory","subject":"hackerman+multiuser+` + testID + `"},{"provider":"google","subject":"hackerman+multiuser+` + testID + `"}]}`)}}, + Config: sqlxx.JSONRawMessage(`{"providers":[{"provider":"ory","subject":"hackerman+multiuser+` + testID + `"},{"provider":"google","subject":"hackerman+multiuser+` + testID + `"}]}`), + }, + }, SchemaID: config.DefaultIdentityTraitsSchemaID, }, } agents := testhelpers.AddAndLoginIdentities(t, reg, publicTS, users) - var newProfileFlow = func(t *testing.T, client *http.Client, redirectTo string, exp time.Duration) *settings.Flow { + newProfileFlow := func(t *testing.T, client *http.Client, redirectTo string, exp time.Duration) *settings.Flow { req, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), x.ParseUUID(string(testhelpers.InitializeSettingsFlowViaBrowser(t, client, false, publicTS).Id))) require.NoError(t, err) @@ -131,7 +149,7 @@ func TestSettingsStrategy(t *testing.T) { } // does the same as new profile request but uses the SDK - var nprSDK = func(t *testing.T, client *http.Client, redirectTo string, exp time.Duration) *kratos.SettingsFlow { + nprSDK := func(t *testing.T, client *http.Client, redirectTo string, exp time.Duration) *kratos.SettingsFlow { return testhelpers.InitializeSettingsFlowViaBrowser(t, client, false, publicTS) } @@ -208,11 +226,11 @@ func TestSettingsStrategy(t *testing.T) { } }) - var action = func(req *kratos.SettingsFlow) string { + action := func(req *kratos.SettingsFlow) string { return req.Ui.Action } - var checkCredentials = func(t *testing.T, shouldExist bool, iid uuid.UUID, provider, subject string, expectTokens bool) { + checkCredentials := func(t *testing.T, shouldExist bool, iid uuid.UUID, provider, subject string, expectTokens bool) { actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), iid) require.NoError(t, err) @@ -242,7 +260,7 @@ func TestSettingsStrategy(t *testing.T) { require.EqualValues(t, shouldExist, found) } - var reset = func(t *testing.T) func() { + reset := func(t *testing.T) func() { return func() { conf.MustSet(ctx, config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, time.Minute*5) agents = testhelpers.AddAndLoginIdentities(t, reg, publicTS, users) @@ -250,20 +268,20 @@ func TestSettingsStrategy(t *testing.T) { } t.Run("suite=unlink", func(t *testing.T) { - var unlink = func(t *testing.T, agent, provider string) (body []byte, res *http.Response, req *kratos.SettingsFlow) { + unlink := func(t *testing.T, agent, provider string) (body []byte, res *http.Response, req *kratos.SettingsFlow) { req = nprSDK(t, agents[agent], "", time.Hour) body, res = testhelpers.HTTPPostForm(t, agents[agent], action(req), &url.Values{"csrf_token": {x.FakeCSRFToken}, "unlink": {provider}}) return } - var unlinkInvalid = func(agent, provider, errorMessage string) func(t *testing.T) { + unlinkInvalid := func(agent, provider, errorMessage string) func(t *testing.T) { return func(t *testing.T) { body, res, req := unlink(t, agent, provider) assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?flow="+req.Id) - //assert.EqualValues(t, identity.CredentialsTypeOIDC.String(), gjson.GetBytes(body, "active").String()) + // assert.EqualValues(t, identity.CredentialsTypeOIDC.String(), gjson.GetBytes(body, "active").String()) // The original options to link google and github are still there t.Run("flow=fetch", func(t *testing.T) { @@ -302,7 +320,7 @@ func TestSettingsStrategy(t *testing.T) { t.Run("case=should not be able to unlink a connection without a privileged session", func(t *testing.T) { agent, provider := "githuber", "github" - var runUnauthed = func(t *testing.T) *kratos.SettingsFlow { + runUnauthed := func(t *testing.T) *kratos.SettingsFlow { conf.MustSet(ctx, config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, time.Millisecond) time.Sleep(time.Millisecond) t.Cleanup(reset(t)) @@ -311,7 +329,7 @@ func TestSettingsStrategy(t *testing.T) { rs, _, err := testhelpers.NewSDKCustomClient(publicTS, agents[agent]).FrontendApi.GetSettingsFlow(context.Background()).Id(req.Id).Execute() require.NoError(t, err) - require.EqualValues(t, settings.StateShowForm, rs.State) + require.EqualValues(t, flow.StateShowForm, rs.State) checkCredentials(t, true, users[agent].ID, provider, "hackerman+github+"+testID, false) @@ -340,19 +358,19 @@ func TestSettingsStrategy(t *testing.T) { }) t.Run("suite=link", func(t *testing.T) { - var link = func(t *testing.T, agent, provider string) (body []byte, res *http.Response, req *kratos.SettingsFlow) { + link := func(t *testing.T, agent, provider string) (body []byte, res *http.Response, req *kratos.SettingsFlow) { req = nprSDK(t, agents[agent], "", time.Hour) body, res = testhelpers.HTTPPostForm(t, agents[agent], action(req), &url.Values{"csrf_token": {x.FakeCSRFToken}, "link": {provider}}) return } - var linkInvalid = func(agent, provider string) func(t *testing.T) { + linkInvalid := func(agent, provider string) func(t *testing.T) { return func(t *testing.T) { body, res, req := link(t, agent, provider) assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/settings?flow="+req.Id) - //assert.EqualValues(t, identity.CredentialsTypeOIDC.String(), gjson.GetBytes(body, "active").String()) + // assert.EqualValues(t, identity.CredentialsTypeOIDC.String(), gjson.GetBytes(body, "active").String()) assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), publicTS.URL+settings.RouteSubmitFlow+"?flow=") // The original options to link google and github are still there @@ -427,7 +445,7 @@ func TestSettingsStrategy(t *testing.T) { updatedFlowSDK, _, err := testhelpers.NewSDKCustomClient(publicTS, agents[agent]).FrontendApi.GetSettingsFlow(context.Background()).Id(originalFlow.Id).Execute() require.NoError(t, err) - require.EqualValues(t, settings.StateSuccess, updatedFlowSDK.State) + require.EqualValues(t, flow.StateSuccess, updatedFlowSDK.State) t.Run("flow=original", func(t *testing.T) { snapshotx.SnapshotTExcept(t, originalFlow.Ui.Nodes, []string{"0.attributes.value", "1.attributes.value"}) @@ -454,7 +472,7 @@ func TestSettingsStrategy(t *testing.T) { rs, _, err := testhelpers.NewSDKCustomClient(publicTS, agents[agent]).FrontendApi.GetSettingsFlow(context.Background()).Id(req.Id).Execute() require.NoError(t, err) - require.EqualValues(t, settings.StateSuccess, rs.State) + require.EqualValues(t, flow.StateSuccess, rs.State) snapshotx.SnapshotTExcept(t, rs.Ui.Nodes, []string{"0.attributes.value", "1.attributes.value"}) @@ -529,7 +547,7 @@ func TestSettingsStrategy(t *testing.T) { agent, provider := "githuber", "google" subject = "hackerman+new+google+" + testID - var runUnauthed = func(t *testing.T) *kratos.SettingsFlow { + runUnauthed := func(t *testing.T) *kratos.SettingsFlow { conf.MustSet(ctx, config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, time.Millisecond) time.Sleep(time.Millisecond) t.Cleanup(reset(t)) @@ -538,7 +556,7 @@ func TestSettingsStrategy(t *testing.T) { rs, _, err := testhelpers.NewSDKCustomClient(publicTS, agents[agent]).FrontendApi.GetSettingsFlow(context.Background()).Id(req.Id).Execute() require.NoError(t, err) - require.EqualValues(t, settings.StateShowForm, rs.State) + require.EqualValues(t, flow.StateShowForm, rs.State) checkCredentials(t, false, users[agent].ID, provider, subject, true) @@ -675,10 +693,12 @@ func TestPopulateSettingsMethod(t *testing.T) { oidc.NewUnlinkNode("google"), }, withpw: true, - i: &identity.Credentials{Type: identity.CredentialsTypeOIDC, Identifiers: []string{ - "google:1234", + i: &identity.Credentials{ + Type: identity.CredentialsTypeOIDC, Identifiers: []string{ + "google:1234", + }, + Config: []byte(`{"providers":[{"provider":"google","subject":"1234"}]}`), }, - Config: []byte(`{"providers":[{"provider":"google","subject":"1234"}]}`)}, }, { c: defaultConfig, @@ -688,11 +708,13 @@ func TestPopulateSettingsMethod(t *testing.T) { oidc.NewUnlinkNode("google"), oidc.NewUnlinkNode("facebook"), }, - i: &identity.Credentials{Type: identity.CredentialsTypeOIDC, Identifiers: []string{ - "google:1234", - "facebook:1234", + i: &identity.Credentials{ + Type: identity.CredentialsTypeOIDC, Identifiers: []string{ + "google:1234", + "facebook:1234", + }, + Config: []byte(`{"providers":[{"provider":"google","subject":"1234"},{"provider":"facebook","subject":"1234"}]}`), }, - Config: []byte(`{"providers":[{"provider":"google","subject":"1234"},{"provider":"facebook","subject":"1234"}]}`)}, }, } { t.Run("iteration="+strconv.Itoa(k), func(t *testing.T) { diff --git a/selfservice/strategy/password/login.go b/selfservice/strategy/password/login.go index 30010b80eb54..7a56ebaf45d4 100644 --- a/selfservice/strategy/password/login.go +++ b/selfservice/strategy/password/login.go @@ -51,7 +51,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, err } - if err := flow.MethodEnabledAndAllowedFromRequest(r, s.ID().String(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil { return nil, err } diff --git a/selfservice/strategy/password/registration.go b/selfservice/strategy/password/registration.go index 57ebca990923..ac5c91789745 100644 --- a/selfservice/strategy/password/registration.go +++ b/selfservice/strategy/password/registration.go @@ -78,7 +78,7 @@ func (s *Strategy) decode(p *UpdateRegistrationFlowWithPasswordMethod, r *http.R } func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registration.Flow, i *identity.Identity) (err error) { - if err := flow.MethodEnabledAndAllowedFromRequest(r, s.ID().String(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil { return err } diff --git a/selfservice/strategy/password/settings.go b/selfservice/strategy/password/settings.go index 45e6bc045650..4ba0b115e53a 100644 --- a/selfservice/strategy/password/settings.go +++ b/selfservice/strategy/password/settings.go @@ -75,7 +75,7 @@ func (s *Strategy) Settings(w http.ResponseWriter, r *http.Request, f *settings. return ctxUpdate, s.handleSettingsError(w, r, ctxUpdate, &p, err) } - if err := flow.MethodEnabledAndAllowedFromRequest(r, s.SettingsStrategyID(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.SettingsStrategyID(), s.d); err != nil { return ctxUpdate, s.handleSettingsError(w, r, ctxUpdate, &p, err) } @@ -109,7 +109,7 @@ func (s *Strategy) continueSettingsFlow( w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p *updateSettingsFlowWithPasswordMethod, ) error { - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), p.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), flow.SettingsFlow, s.SettingsStrategyID(), p.Method, s.d); err != nil { return err } diff --git a/selfservice/strategy/password/strategy.go b/selfservice/strategy/password/strategy.go index 1e64544edee1..2993c428b701 100644 --- a/selfservice/strategy/password/strategy.go +++ b/selfservice/strategy/password/strategy.go @@ -26,9 +26,11 @@ import ( "github.com/ory/kratos/x" ) -var _ login.Strategy = new(Strategy) -var _ registration.Strategy = new(Strategy) -var _ identity.ActiveCredentialsCounter = new(Strategy) +var ( + _ login.Strategy = new(Strategy) + _ registration.Strategy = new(Strategy) + _ identity.ActiveCredentialsCounter = new(Strategy) +) type registrationStrategyDependencies interface { x.LoggingProvider diff --git a/selfservice/strategy/profile/strategy.go b/selfservice/strategy/profile/strategy.go index 5c4a68be9a78..e94d779aef61 100644 --- a/selfservice/strategy/profile/strategy.go +++ b/selfservice/strategy/profile/strategy.go @@ -116,7 +116,7 @@ func (s *Strategy) Settings(w http.ResponseWriter, r *http.Request, f *settings. return ctxUpdate, s.handleSettingsError(w, r, ctxUpdate, nil, &p, err) } - if err := flow.MethodEnabledAndAllowedFromRequest(r, s.SettingsStrategyID(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.SettingsStrategyID(), s.d); err != nil { return ctxUpdate, err } @@ -144,7 +144,7 @@ func (s *Strategy) Settings(w http.ResponseWriter, r *http.Request, f *settings. } func (s *Strategy) continueFlow(w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p *updateSettingsFlowWithProfileMethod) error { - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), p.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), flow.SettingsFlow, s.SettingsStrategyID(), p.Method, s.d); err != nil { return err } diff --git a/selfservice/strategy/profile/strategy_test.go b/selfservice/strategy/profile/strategy_test.go index bb09a2b925a0..f67407fe799f 100644 --- a/selfservice/strategy/profile/strategy_test.go +++ b/selfservice/strategy/profile/strategy_test.go @@ -32,6 +32,7 @@ import ( "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/x" "github.com/ory/x/assertx" @@ -189,7 +190,7 @@ func TestStrategyTraits(t *testing.T) { t.Run("description=hydrate the proper fields", func(t *testing.T) { setPrivileged(t) - var run = func(t *testing.T, id *identity.Identity, payload *kratos.SettingsFlow, route string) { + run := func(t *testing.T, id *identity.Identity, payload *kratos.SettingsFlow, route string) { assert.NotEmpty(t, payload.Identity) assert.Equal(t, id.ID.String(), string(payload.Identity.Id)) assert.JSONEq(t, string(id.Traits), x.MustEncodeJSON(t, payload.Identity.Traits)) @@ -230,7 +231,7 @@ func TestStrategyTraits(t *testing.T) { }) }) - var expectValidationError = func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { + expectValidationError := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { return testhelpers.SubmitSettingsForm(t, isAPI, isSPA, hc, publicTS, values, testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK), testhelpers.ExpectURL(isAPI || isSPA, publicTS.URL+settings.RouteSubmitFlow, conf.SelfServiceFlowSettingsUI(ctx).String())) @@ -239,7 +240,7 @@ func TestStrategyTraits(t *testing.T) { t.Run("description=should come back with form errors if some profile data is invalid", func(t *testing.T) { setPrivileged(t) - var check = func(t *testing.T, actual string) { + check := func(t *testing.T, actual string) { assert.NotEmpty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String(), "%s", actual) assert.Equal(t, "too-short", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_long_string).attributes.value").String(), "%s", actual) assert.Equal(t, "bazbar", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.stringy).attributes.value").String(), "%s", actual) @@ -247,7 +248,7 @@ func TestStrategyTraits(t *testing.T) { assert.Equal(t, "length must be >= 25, but got 9", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_long_string).messages.0.text").String(), "%s", actual) } - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set("method", "profile") v.Set("traits.should_long_string", "too-short") v.Set("traits.stringy", "bazbar") @@ -298,7 +299,7 @@ func TestStrategyTraits(t *testing.T) { }) t.Run("description=should end up at the login endpoint if trying to update protected field without sudo mode", func(t *testing.T) { - var run = func(t *testing.T, config *kratos.SettingsFlow, isAPI bool, c *http.Client) *http.Response { + run := func(t *testing.T, config *kratos.SettingsFlow, isAPI bool, c *http.Client) *http.Response { time.Sleep(time.Millisecond) values := testhelpers.SDKFormFieldsToURLValues(config.Ui.Nodes) @@ -343,7 +344,7 @@ func TestStrategyTraits(t *testing.T) { defer res.Body.Close() assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body) - assert.EqualValues(t, settings.StateSuccess, gjson.GetBytes(body, "state").String(), "%s", body) + assert.EqualValues(t, flow.StateSuccess, gjson.GetBytes(body, "state").String(), "%s", body) }) }) }) @@ -351,14 +352,14 @@ func TestStrategyTraits(t *testing.T) { t.Run("flow=fail first update", func(t *testing.T) { setPrivileged(t) - var check = func(t *testing.T, actual string) { - assert.EqualValues(t, settings.StateShowForm, gjson.Get(actual, "state").String(), "%s", actual) + check := func(t *testing.T, actual string) { + assert.EqualValues(t, flow.StateShowForm, gjson.Get(actual, "state").String(), "%s", actual) assert.Equal(t, "1", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_big_number).attributes.value").String(), "%s", actual) assert.Equal(t, "must be >= 1200 but found 1", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_big_number).messages.0.text").String(), "%s", actual) assert.Equal(t, "foobar", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.stringy).attributes.value").String(), "%s", actual) // sanity check if original payload is still here } - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set("method", settings.StrategyProfile) v.Set("traits.should_big_number", "1") } @@ -379,8 +380,8 @@ func TestStrategyTraits(t *testing.T) { t.Run("flow=fail second update", func(t *testing.T) { setPrivileged(t) - var check = func(t *testing.T, actual string) { - assert.EqualValues(t, settings.StateShowForm, gjson.Get(actual, "state").String(), "%s", actual) + check := func(t *testing.T, actual string) { + assert.EqualValues(t, flow.StateShowForm, gjson.Get(actual, "state").String(), "%s", actual) assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_big_number).messages.0.text").String(), "%s", actual) assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_big_number).attributes.value").String(), "%s", actual) @@ -394,7 +395,7 @@ func TestStrategyTraits(t *testing.T) { assert.Equal(t, "foobar", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.stringy).attributes.value").String(), "%s", actual) // sanity check if original payload is still here } - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set("method", settings.StrategyProfile) v.Del("traits.should_big_number") v.Set("traits.should_long_string", "short") @@ -414,7 +415,7 @@ func TestStrategyTraits(t *testing.T) { }) }) - var expectSuccess = func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { + expectSuccess := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { return testhelpers.SubmitSettingsForm(t, isAPI, isSPA, hc, publicTS, values, http.StatusOK, testhelpers.ExpectURL(isAPI || isSPA, publicTS.URL+settings.RouteSubmitFlow, conf.SelfServiceFlowSettingsUI(ctx).String())) @@ -423,8 +424,8 @@ func TestStrategyTraits(t *testing.T) { t.Run("flow=succeed with final request", func(t *testing.T) { setPrivileged(t) - var check = func(t *testing.T, actual string) { - assert.EqualValues(t, settings.StateSuccess, gjson.Get(actual, "state").String(), "%s", actual) + check := func(t *testing.T, actual string) { + assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), "%s", actual) assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.numby).attributes.errors").Value(), "%s", actual) assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_big_number).attributes.errors").Value(), "%s", actual) @@ -435,7 +436,7 @@ func TestStrategyTraits(t *testing.T) { assert.Equal(t, "this is such a long string, amazing stuff!", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_long_string).attributes.value").Value(), "%s", actual) } - var payload = func(newEmail string) func(v url.Values) { + payload := func(newEmail string) func(v url.Values) { return func(v url.Values) { v.Set("method", settings.StrategyProfile) v.Set("traits.email", newEmail) @@ -463,11 +464,11 @@ func TestStrategyTraits(t *testing.T) { t.Run("flow=try another update with invalid data", func(t *testing.T) { setPrivileged(t) - var check = func(t *testing.T, actual string) { - assert.EqualValues(t, settings.StateShowForm, gjson.Get(actual, "state").String(), "%s", actual) + check := func(t *testing.T, actual string) { + assert.EqualValues(t, flow.StateShowForm, gjson.Get(actual, "state").String(), "%s", actual) } - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set("method", settings.StrategyProfile) v.Set("traits.should_long_string", "short") } @@ -526,8 +527,8 @@ func TestStrategyTraits(t *testing.T) { conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceSettingsAfter, settings.StrategyProfile), nil) }) - var check = func(t *testing.T, actual, newEmail string) { - assert.EqualValues(t, settings.StateSuccess, gjson.Get(actual, "state").String(), "%s", actual) + check := func(t *testing.T, actual, newEmail string) { + assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), "%s", actual) assert.Equal(t, newEmail, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.email).attributes.value").Value(), "%s", actual) m, err := reg.CourierPersister().LatestQueuedMessage(context.Background()) @@ -535,7 +536,7 @@ func TestStrategyTraits(t *testing.T) { assert.Contains(t, m.Subject, "verify your email address") } - var payload = func(newEmail string) func(v url.Values) { + payload := func(newEmail string) func(v url.Values) { return func(v url.Values) { v.Set("method", settings.StrategyProfile) v.Set("traits.email", newEmail) @@ -564,8 +565,8 @@ func TestStrategyTraits(t *testing.T) { t.Run("description=should update protected field with sudo mode", func(t *testing.T) { setPrivileged(t) - var check = func(t *testing.T, newEmail string, actual string) { - assert.EqualValues(t, settings.StateSuccess, gjson.Get(actual, "state").String(), "%s", actual) + check := func(t *testing.T, newEmail string, actual string) { + assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), "%s", actual) assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.numby).attributes.errors").Value(), "%s", actual) assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_big_number).attributes.errors").Value(), "%s", actual) assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.should_long_string).attributes.errors").Value(), "%s", actual) @@ -573,7 +574,7 @@ func TestStrategyTraits(t *testing.T) { assert.Equal(t, "foobar", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.stringy).attributes.value").String(), "%s", actual) // sanity check if original payload is still here } - var payload = func(email string) func(v url.Values) { + payload := func(email string) func(v url.Values) { return func(v url.Values) { v.Set("method", settings.StrategyProfile) v.Set("traits.email", email) diff --git a/selfservice/strategy/totp/login.go b/selfservice/strategy/totp/login.go index e3840eba1b9b..bc9816d265f3 100644 --- a/selfservice/strategy/totp/login.go +++ b/selfservice/strategy/totp/login.go @@ -90,7 +90,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, err } - if err := flow.MethodEnabledAndAllowedFromRequest(r, s.ID().String(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil { return nil, err } diff --git a/selfservice/strategy/totp/settings.go b/selfservice/strategy/totp/settings.go index 928c288be365..0587e87e2d6a 100644 --- a/selfservice/strategy/totp/settings.go +++ b/selfservice/strategy/totp/settings.go @@ -94,10 +94,10 @@ func (s *Strategy) Settings(w http.ResponseWriter, r *http.Request, f *settings. if p.UnlinkTOTP { // This is a submit so we need to manually set the type to TOTP p.Method = s.SettingsStrategyID() - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), p.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil { return nil, s.handleSettingsError(w, r, ctxUpdate, &p, err) } - } else if err := flow.MethodEnabledAndAllowedFromRequest(r, s.SettingsStrategyID(), s.d); err != nil { + } else if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.SettingsStrategyID(), s.d); err != nil { return ctxUpdate, s.handleSettingsError(w, r, ctxUpdate, &p, err) } @@ -127,7 +127,7 @@ func (s *Strategy) continueSettingsFlow( w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p *updateSettingsFlowWithTotpMethod, ) error { - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), p.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), flow.SettingsFlow, s.SettingsStrategyID(), p.Method, s.d); err != nil { return err } diff --git a/selfservice/strategy/totp/settings_test.go b/selfservice/strategy/totp/settings_test.go index b9b294ffdf4e..0fd479f1b220 100644 --- a/selfservice/strategy/totp/settings_test.go +++ b/selfservice/strategy/totp/settings_test.go @@ -148,7 +148,7 @@ func TestCompleteSettings(t *testing.T) { }) id, _, key := createIdentity(t, reg) - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set("totp_unlink", "true") } @@ -190,7 +190,7 @@ func TestCompleteSettings(t *testing.T) { }) id := createIdentityWithoutTOTP(t, reg) - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set(node.TOTPCode, "111111") } @@ -225,7 +225,7 @@ func TestCompleteSettings(t *testing.T) { }) t.Run("type=unlink TOTP device", func(t *testing.T) { - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set("totp_unlink", "true") } @@ -239,7 +239,7 @@ func TestCompleteSettings(t *testing.T) { actual, res := doAPIFlow(t, payload, id) assert.Equal(t, http.StatusOK, res.StatusCode) assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) - assert.EqualValues(t, settings.StateSuccess, gjson.Get(actual, "state").String(), actual) + assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual) checkIdentity(t, id) }) @@ -248,7 +248,7 @@ func TestCompleteSettings(t *testing.T) { actual, res := doBrowserFlow(t, true, payload, id) assert.Equal(t, http.StatusOK, res.StatusCode) assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) - assert.EqualValues(t, settings.StateSuccess, gjson.Get(actual, "state").String(), actual) + assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual) checkIdentity(t, id) }) @@ -257,13 +257,13 @@ func TestCompleteSettings(t *testing.T) { actual, res := doBrowserFlow(t, false, payload, id) assert.Equal(t, http.StatusOK, res.StatusCode) assert.Contains(t, res.Request.URL.String(), uiTS.URL) - assert.EqualValues(t, settings.StateSuccess, gjson.Get(actual, "state").String(), actual) + assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual) checkIdentity(t, id) }) }) t.Run("type=set up TOTP device but code is incorrect", func(t *testing.T) { - var payload = func(v url.Values) { + payload := func(v url.Values) { v.Set(node.TOTPCode, "111111") } @@ -332,10 +332,10 @@ func TestCompleteSettings(t *testing.T) { if isAPI || isSPA { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) - assert.EqualValues(t, settings.StateSuccess, gjson.Get(actual, "state").String(), actual) + assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) - assert.EqualValues(t, settings.StateSuccess, gjson.Get(actual, "state").String(), actual) + assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual) } actualFlow, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(f.Id)) diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=browser.json index 0a7494871340..eb348b172ab6 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=browser.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=browser.json @@ -82,5 +82,6 @@ ] }, "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=spa.json index 0a7494871340..eb348b172ab6 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=spa.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=spa.json @@ -82,5 +82,6 @@ ] }, "refresh": false, - "requested_aal": "aal1" + "requested_aal": "aal1", + "state": "" } diff --git a/selfservice/strategy/webauthn/login.go b/selfservice/strategy/webauthn/login.go index 33e2689ea315..857f9c55431d 100644 --- a/selfservice/strategy/webauthn/login.go +++ b/selfservice/strategy/webauthn/login.go @@ -211,7 +211,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, flow.ErrStrategyNotResponsible } - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), p.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil { return nil, s.handleLoginError(r, f, err) } diff --git a/selfservice/strategy/webauthn/registration.go b/selfservice/strategy/webauthn/registration.go index 96b67244abaa..2880e3ecf9dc 100644 --- a/selfservice/strategy/webauthn/registration.go +++ b/selfservice/strategy/webauthn/registration.go @@ -113,7 +113,7 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat } p.Method = s.SettingsStrategyID() - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), p.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil { return s.handleRegistrationError(w, r, f, &p, err) } diff --git a/selfservice/strategy/webauthn/settings.go b/selfservice/strategy/webauthn/settings.go index a6e513a20c99..7a663f6fa592 100644 --- a/selfservice/strategy/webauthn/settings.go +++ b/selfservice/strategy/webauthn/settings.go @@ -112,7 +112,7 @@ func (s *Strategy) Settings(w http.ResponseWriter, r *http.Request, f *settings. if len(p.Register+p.Remove) > 0 { // This method has only two submit buttons p.Method = s.SettingsStrategyID() - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), p.Method, s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil { return nil, s.handleSettingsError(w, r, ctxUpdate, &p, err) } } else { @@ -146,7 +146,7 @@ func (s *Strategy) continueSettingsFlow( ctxUpdate *settings.UpdateContext, p *updateSettingsFlowWithWebAuthnMethod, ) error { if len(p.Register+p.Remove) > 0 { - if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil { + if err := flow.MethodEnabledAndAllowed(r.Context(), flow.SettingsFlow, s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil { return err } diff --git a/selfservice/strategy/webauthn/settings_test.go b/selfservice/strategy/webauthn/settings_test.go index 04f571e7ab5e..27413df38b16 100644 --- a/selfservice/strategy/webauthn/settings_test.go +++ b/selfservice/strategy/webauthn/settings_test.go @@ -334,7 +334,7 @@ func TestCompleteSettings(t *testing.T) { } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } - assert.EqualValues(t, settings.StateSuccess, gjson.Get(body, "state").String(), body) + assert.EqualValues(t, flow.StateSuccess, gjson.Get(body, "state").String(), body) actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) require.NoError(t, err) @@ -386,7 +386,7 @@ func TestCompleteSettings(t *testing.T) { } t.Run("response", func(t *testing.T) { - assert.EqualValues(t, settings.StateShowForm, gjson.Get(body, "state").String(), body) + assert.EqualValues(t, flow.StateShowForm, gjson.Get(body, "state").String(), body) snapshotx.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes.#(attributes.name==webauthn_remove)").String()), nil) actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) @@ -426,7 +426,7 @@ func TestCompleteSettings(t *testing.T) { } t.Run("response", func(t *testing.T) { - assert.EqualValues(t, settings.StateSuccess, gjson.Get(body, "state").String(), body) + assert.EqualValues(t, flow.StateSuccess, gjson.Get(body, "state").String(), body) actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) require.NoError(t, err) _, ok := actual.GetCredentials(identity.CredentialsTypeWebAuthn) @@ -463,7 +463,7 @@ func TestCompleteSettings(t *testing.T) { } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } - assert.EqualValues(t, settings.StateSuccess, gjson.Get(body, "state").String(), body) + assert.EqualValues(t, flow.StateSuccess, gjson.Get(body, "state").String(), body) } actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) @@ -496,7 +496,7 @@ func TestCompleteSettings(t *testing.T) { } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } - assert.EqualValues(t, settings.StateSuccess, json.RawMessage(gjson.Get(body, "state").String())) + assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(body, "state").String())) actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) require.NoError(t, err) diff --git a/test/e2e/.go-version b/test/e2e/.go-version new file mode 100644 index 000000000000..6681c8c19ab4 --- /dev/null +++ b/test/e2e/.go-version @@ -0,0 +1 @@ +1.19.8 diff --git a/test/e2e/cypress/integration/profiles/code/registration/success.spec.ts b/test/e2e/cypress/integration/profiles/code/registration/success.spec.ts new file mode 100644 index 000000000000..6b34e7aeea71 --- /dev/null +++ b/test/e2e/cypress/integration/profiles/code/registration/success.spec.ts @@ -0,0 +1,71 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { appPrefix, APP_URL, gen } from "../../../../helpers" +import { routes as express } from "../../../../helpers/express" +import { routes as react } from "../../../../helpers/react" + +context("Registration success with code method", () => { + ;[ + { + route: express.registration, + app: "express" as "express", + profile: "code", + }, + { + route: react.registration, + app: "react" as "react", + profile: "code", + } + ].forEach(({ route, profile, app }) => { + describe(`for app ${app}`, () => { + before(() => { + cy.deleteMail() + cy.useConfigProfile(profile) + cy.proxy(app) + }) + + beforeEach(() => { + cy.deleteMail() + cy.clearAllCookies() + cy.visit(route) + }) + + it("should sign up and be logged in", () => { + const email = gen.email() + + cy.get(appPrefix(app) + 'input[name="traits"]').should("not.exist") + cy.get('input[name="traits.email"]').type(email) + + cy.submitCodeForm() + + cy.url().should("contain", "registration") + cy.getRegistrationCodeFromEmail(email).then((code) => { + cy.get("input[name=code]").type(code) + cy.get("button[name=method][value=code]").click() + }) + + cy.get("[data-testid='node/anchor/continue']").click() + + if (app === "express") { + cy.get('a[href*="sessions"').click() + } + cy.get("pre").should("contain.text", email) + + cy.getSession().should((session) => { + const { identity } = session + expect(identity.id).to.not.be.empty + expect(identity.verifiable_addresses).to.have.length(1) + expect(identity.schema_id).to.equal("default") + expect(identity.schema_url).to.equal(`${APP_URL}/schemas/ZGVmYXVsdA`) + expect(identity.traits.website).to.equal(website) + expect(identity.traits.email).to.equal(email) + expect(identity.traits.age).to.equal(age) + expect(identity.traits.tos).to.equal(true) + }) + }) + + }) + + }) +}) diff --git a/test/e2e/cypress/support/commands.ts b/test/e2e/cypress/support/commands.ts index 91622e42dd2c..931bd6f9510f 100644 --- a/test/e2e/cypress/support/commands.ts +++ b/test/e2e/cypress/support/commands.ts @@ -80,7 +80,7 @@ Cypress.Commands.add("proxy", (app: string) => { }) }) -Cypress.Commands.add("shortPrivilegedSessionTime", ({} = {}) => { +Cypress.Commands.add("shortPrivilegedSessionTime", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.settings.privileged_session_max_age = "1ms" return config @@ -109,50 +109,50 @@ Cypress.Commands.add("setDefaultIdentitySchema", (id: string) => { }) }) -Cypress.Commands.add("longPrivilegedSessionTime", ({} = {}) => { +Cypress.Commands.add("longPrivilegedSessionTime", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.settings.privileged_session_max_age = "5m" return config }) }) -Cypress.Commands.add("longVerificationLifespan", ({} = {}) => { +Cypress.Commands.add("longVerificationLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.verification.lifespan = "1m" return config }) }) -Cypress.Commands.add("shortVerificationLifespan", ({} = {}) => { +Cypress.Commands.add("shortVerificationLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.verification.lifespan = "1ms" return config }) }) -Cypress.Commands.add("sessionRequiresNo2fa", ({} = {}) => { +Cypress.Commands.add("sessionRequiresNo2fa", ({ } = {}) => { updateConfigFile((config) => { config.session.whoami.required_aal = "aal1" return config }) }) -Cypress.Commands.add("sessionRequires2fa", ({} = {}) => { +Cypress.Commands.add("sessionRequires2fa", ({ } = {}) => { updateConfigFile((config) => { config.session.whoami.required_aal = "highest_available" return config }) }) -Cypress.Commands.add("shortLinkLifespan", ({} = {}) => { +Cypress.Commands.add("shortLinkLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.methods.link.config.lifespan = "1ms" return config }) }) -Cypress.Commands.add("longLinkLifespan", ({} = {}) => { +Cypress.Commands.add("longLinkLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.methods.link.config.lifespan = "1m" return config }) }) -Cypress.Commands.add("shortCodeLifespan", ({} = {}) => { +Cypress.Commands.add("shortCodeLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.methods.code.config.lifespan = "1ms" return config @@ -173,28 +173,28 @@ Cypress.Commands.add("longLifespan", (strategy: Strategy) => { }) }) -Cypress.Commands.add("longCodeLifespan", ({} = {}) => { +Cypress.Commands.add("longCodeLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.methods.code.config.lifespan = "1m" return config }) }) -Cypress.Commands.add("shortCodeLifespan", ({} = {}) => { +Cypress.Commands.add("shortCodeLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.methods.code.config.lifespan = "1ms" return config }) }) -Cypress.Commands.add("longCodeLifespan", ({} = {}) => { +Cypress.Commands.add("longCodeLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.methods.code.config.lifespan = "1m" return config }) }) -Cypress.Commands.add("longRecoveryLifespan", ({} = {}) => { +Cypress.Commands.add("longRecoveryLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.recovery.lifespan = "1m" return config @@ -221,20 +221,20 @@ Cypress.Commands.add("setPostPasswordRegistrationHooks", (hooks) => { cy.setupHooks("registration", "after", "password", hooks) }) -Cypress.Commands.add("shortLoginLifespan", ({} = {}) => { +Cypress.Commands.add("shortLoginLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.login.lifespan = "100ms" return config }) }) -Cypress.Commands.add("longLoginLifespan", ({} = {}) => { +Cypress.Commands.add("longLoginLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.login.lifespan = "1h" return config }) }) -Cypress.Commands.add("shortRecoveryLifespan", ({} = {}) => { +Cypress.Commands.add("shortRecoveryLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.recovery.lifespan = "1ms" return config @@ -249,7 +249,7 @@ Cypress.Commands.add("requireStrictAal", () => { }) }) -Cypress.Commands.add("useLaxAal", ({} = {}) => { +Cypress.Commands.add("useLaxAal", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.settings.required_aal = "aal1" config.session.whoami.required_aal = "aal1" @@ -257,21 +257,21 @@ Cypress.Commands.add("useLaxAal", ({} = {}) => { }) }) -Cypress.Commands.add("disableVerification", ({} = {}) => { +Cypress.Commands.add("disableVerification", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.verification.enabled = false return config }) }) -Cypress.Commands.add("enableVerification", ({} = {}) => { +Cypress.Commands.add("enableVerification", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.verification.enabled = true return config }) }) -Cypress.Commands.add("enableRecovery", ({} = {}) => { +Cypress.Commands.add("enableRecovery", ({ } = {}) => { updateConfigFile((config) => { if (!config.selfservice.flows.recovery) { config.selfservice.flows.recovery = {} @@ -302,28 +302,28 @@ Cypress.Commands.add("disableRecoveryStrategy", (strategy: Strategy) => { }) }) -Cypress.Commands.add("disableRecovery", ({} = {}) => { +Cypress.Commands.add("disableRecovery", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.recovery.enabled = false return config }) }) -Cypress.Commands.add("disableRegistration", ({} = {}) => { +Cypress.Commands.add("disableRegistration", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.registration.enabled = false return config }) }) -Cypress.Commands.add("enableRegistration", ({} = {}) => { +Cypress.Commands.add("enableRegistration", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.registration.enabled = true return config }) }) -Cypress.Commands.add("useLaxAal", ({} = {}) => { +Cypress.Commands.add("useLaxAal", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.settings.required_aal = "aal1" config.session.whoami.required_aal = "aal1" @@ -655,21 +655,21 @@ Cypress.Commands.add( }, ) -Cypress.Commands.add("shortRegisterLifespan", ({} = {}) => { +Cypress.Commands.add("shortRegisterLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.registration.lifespan = "100ms" return config }) }) -Cypress.Commands.add("longRegisterLifespan", ({} = {}) => { +Cypress.Commands.add("longRegisterLifespan", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.flows.registration.lifespan = "1h" return config }) }) -Cypress.Commands.add("browserReturnUrlOry", ({} = {}) => { +Cypress.Commands.add("browserReturnUrlOry", ({ } = {}) => { updateConfigFile((config) => { config.selfservice.allowed_return_urls = [ "https://www.ory.sh/", @@ -679,7 +679,7 @@ Cypress.Commands.add("browserReturnUrlOry", ({} = {}) => { }) }) -Cypress.Commands.add("remoteCourierRecoveryTemplates", ({} = {}) => { +Cypress.Commands.add("remoteCourierRecoveryTemplates", ({ } = {}) => { updateConfigFile((config) => { config.courier.templates = { recovery: { @@ -709,7 +709,7 @@ Cypress.Commands.add("remoteCourierRecoveryTemplates", ({} = {}) => { }) }) -Cypress.Commands.add("remoteCourierRecoveryCodeTemplates", ({} = {}) => { +Cypress.Commands.add("remoteCourierRecoveryCodeTemplates", ({ } = {}) => { updateConfigFile((config) => { config.courier.templates = { recovery_code: { @@ -1210,6 +1210,11 @@ Cypress.Commands.add("submitProfileForm", () => { cy.get('[name="method"][value="profile"]:disabled').should("not.exist") }) +Cypress.Commands.add("submitCodeForm", () => { + cy.get('[name="method"][value="code"]').click() + cy.get('[name="method"][value="code"]:disabled').should("not.exist") +}) + Cypress.Commands.add("clickWebAuthButton", (type: string) => { cy.get('*[data-testid="node/script/webauthn_script"]').should("exist") cy.wait(500) // Wait for script to load @@ -1375,3 +1380,26 @@ Cypress.Commands.add("getVerificationCodeFromEmail", (email) => { return code }) }) + +Cypress.Commands.add("enableRegistrationViaCode", (enable: boolean = true) => { + cy.updateConfigFile((config) => { + config.selfservice.methods.code.registration_enabled = enable + return config + }) +}) + +Cypress.Commands.add("getRegistrationCodeFromEmail", (email) => { + return cy + .getMail({ removeMail: true }) + .should((message) => { + expect(message.subject).to.equal("Complete your account registration") + expect(message.toAddresses[0].trim()).to.equal(email) + }) + .then((message) => { + const code = extractRecoveryCode(message.body) + expect(code).to.not.be.undefined + expect(code.length).to.equal(6) + return code + }) +}) + diff --git a/test/e2e/cypress/support/config.d.ts b/test/e2e/cypress/support/config.d.ts index 3ee8e8657b91..54944df51d65 100644 --- a/test/e2e/cypress/support/config.d.ts +++ b/test/e2e/cypress/support/config.d.ts @@ -1,6 +1,3 @@ -// Copyright © 2023 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - /* eslint-disable */ /** * This file was automatically generated by json-schema-to-typescript. @@ -8,186 +5,183 @@ * and run json-schema-to-typescript to regenerate this file. */ -export type OryKratosConfiguration = OryKratosConfiguration1 & - OryKratosConfiguration2 +export type OryKratosConfiguration = OryKratosConfiguration1 & OryKratosConfiguration2; export type OryKratosConfiguration1 = { - [k: string]: unknown | undefined -} + [k: string]: unknown | undefined; +}; /** * Ory Kratos redirects to this URL per default on completion of self-service flows and other browser interaction. Read this [article for more information on browser redirects](https://www.ory.sh/kratos/docs/concepts/browser-redirect-flow-completion). */ -export type RedirectBrowsersToSetURLPerDefault = string +export type RedirectBrowsersToSetURLPerDefault = string; /** * List of URLs that are allowed to be redirected to. A redirection request is made by appending `?return_to=...` to Login, Registration, and other self-service flows. */ -export type AllowedReturnToURLs = string[] +export type AllowedReturnToURLs = string[]; /** * URL where the Settings UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node). */ -export type URLOfTheSettingsPage = string +export type URLOfTheSettingsPage = string; /** * Sets what Authenticator Assurance Level (used for 2FA) is required to access this feature. If set to `highest_available` then this endpoint requires the highest AAL the identity has set up. If set to `aal1` then the identity can access this feature without 2FA. */ -export type RequiredAuthenticatorAssuranceLevel = "aal1" | "highest_available" +export type RequiredAuthenticatorAssuranceLevel = "aal1" | "highest_available"; /** * Define what the hook should do */ export type WebHookConfiguration = | { - [k: string]: unknown | undefined + [k: string]: unknown | undefined; } | { - can_interrupt?: false - [k: string]: unknown | undefined - } -export type SelfServiceHooks = SelfServiceWebHook[] + can_interrupt?: false; + [k: string]: unknown | undefined; + }; +export type SelfServiceHooks = SelfServiceWebHook[]; /** * If set to true will enable [User Registration](https://www.ory.sh/kratos/docs/self-service/flows/user-registration/). */ -export type EnableUserRegistration = boolean +export type EnableUserRegistration = boolean; /** * URL where the Registration UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node). */ -export type RegistrationUIURL = string +export type RegistrationUIURL = string; /** * URL where the Login UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node). */ -export type LoginUIURL = string +export type LoginUIURL = string; /** * If set to true will enable [Email and Phone Verification and Account Activation](https://www.ory.sh/kratos/docs/self-service/flows/verify-email-account-activation/). */ -export type EnableEmailPhoneVerification = boolean +export type EnableEmailPhoneVerification = boolean; /** * URL where the Ory Verify UI is hosted. This is the page where users activate and / or verify their email or telephone number. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node). */ -export type VerifyUIURL = string +export type VerifyUIURL = string; /** * Sets how long the verification request (for the UI interaction) is valid. */ -export type SelfServiceVerificationRequestLifespan = string +export type SelfServiceVerificationRequestLifespan = string; /** * The strategy to use for verification requests */ -export type VerificationStrategy = "link" | "code" +export type VerificationStrategy = "link" | "code"; /** * Whether to notify recipients, if verification was requested for their address. */ -export type NotifyUnknownRecipients = boolean +export type NotifyUnknownRecipients = boolean; /** * If set to true will enable [Account Recovery](https://www.ory.sh/kratos/docs/self-service/flows/password-reset-account-recovery/). */ -export type EnableAccountRecovery = boolean +export type EnableAccountRecovery = boolean; /** * URL where the Ory Recovery UI is hosted. This is the page where users request and complete account recovery. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node). */ -export type RecoveryUIURL = string -export type SelfServiceAfterRecoveryHooks = ( - | SelfServiceWebHook - | SelfServiceSessionRevokerHook -)[] +export type RecoveryUIURL = string; +export type SelfServiceAfterRecoveryHooks = (SelfServiceWebHook | SelfServiceSessionRevokerHook)[]; /** * Sets how long the recovery request is valid. If expired, the user has to redo the flow. */ -export type SelfServiceRecoveryRequestLifespan = string +export type SelfServiceRecoveryRequestLifespan = string; /** * The strategy to use for recovery requests */ -export type RecoveryStrategy = "link" | "code" +export type RecoveryStrategy = "link" | "code"; /** * Whether to notify recipients, if recovery was requested for their account. */ -export type NotifyUnknownRecipients1 = boolean +export type NotifyUnknownRecipients1 = boolean; /** * URL where the Ory Kratos Error UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node). */ -export type OryKratosErrorUIURL = string -export type EnablesProfileManagementMethod = boolean -export type EnablesLinkMethod = boolean -export type OverrideTheBaseURLWhichShouldBeUsedAsTheBaseForRecoveryAndVerificationLinks = - string -export type HowLongALinkIsValidFor = string -export type EnablesCodeMethod = boolean -export type HowLongACodeIsValidFor = string -export type EnablesUsernameEmailAndPasswordMethod = boolean +export type OryKratosErrorUIURL = string; +export type EnablesProfileManagementMethod = boolean; +export type EnablesLinkMethod = boolean; +export type OverrideTheBaseURLWhichShouldBeUsedAsTheBaseForRecoveryAndVerificationLinks = string; +export type HowLongALinkIsValidFor = string; +export type EnablesLoginWithCodeMethod = boolean; +export type EnablesRegistrationWithCodeMethod = boolean; +export type EnablesCodeMethod = boolean; +export type HowLongACodeIsValidFor = string; +export type EnablesUsernameEmailAndPasswordMethod = boolean; /** * Allows changing the default HIBP host to a self hosted version. */ -export type CustomHaveibeenpwnedHost = string +export type CustomHaveibeenpwnedHost = string; /** * If set to false the password validation does not utilize the Have I Been Pwnd API. */ -export type EnableTheHaveIBeenPwnedAPI = boolean +export type EnableTheHaveIBeenPwnedAPI = boolean; /** * Defines how often a password may have been breached before it is rejected. */ -export type AllowPasswordBreaches = number +export type AllowPasswordBreaches = number; /** * If set to false the password validation fails when the network or the Have I Been Pwnd API is down. */ -export type IgnoreLookupNetworkErrors = boolean +export type IgnoreLookupNetworkErrors = boolean; /** * Defines the minimum length of the password. */ -export type MinimumPasswordLength = number +export type MinimumPasswordLength = number; /** * If set to false the password validation does not check for similarity between the password and the user identifier. */ -export type EnablePasswordIdentifierSimilarityCheck = boolean -export type EnablesTheTOTPMethod = boolean +export type EnablePasswordIdentifierSimilarityCheck = boolean; +export type EnablesTheTOTPMethod = boolean; /** * The issuer (e.g. a domain name) will be shown in the TOTP app (e.g. Google Authenticator). It helps the user differentiate between different codes. */ -export type TOTPIssuer = string -export type EnablesTheLookupSecretMethod = boolean -export type EnablesTheWebAuthnMethod = boolean +export type TOTPIssuer = string; +export type EnablesTheLookupSecretMethod = boolean; +export type EnablesTheWebAuthnMethod = boolean; /** * If enabled will have the effect that WebAuthn is used for passwordless flows (as a first factor) and not for multi-factor set ups. With this set to true, users will see an option to sign up with WebAuthn on the registration screen. */ -export type UseForPasswordlessFlows = boolean +export type UseForPasswordlessFlows = boolean; /** * An name to help the user identify this RP. */ -export type RelyingPartyDisplayName = string +export type RelyingPartyDisplayName = string; /** * The id must be a subset of the domain currently in the browser. */ -export type RelyingPartyIdentifier = string +export type RelyingPartyIdentifier = string; /** * An explicit RP origin. If left empty, this defaults to `id`. */ -export type RelyingPartyOrigin = string +export type RelyingPartyOrigin = string; /** * An icon to help the user identify this RP. */ -export type RelyingPartyIcon = string -export type EnablesOpenIDConnectMethod = boolean +export type RelyingPartyIcon = string; +export type EnablesOpenIDConnectMethod = boolean; /** * Can be used to modify the base URL for OAuth2 Redirect URLs. If unset, the Public Base URL will be used. */ -export type BaseURLForOAuth2RedirectURIs = string +export type BaseURLForOAuth2RedirectURIs = string; export type SelfServiceOIDCProvider = SelfServiceOIDCProvider1 & { - id: string - provider: Provider - label?: OptionalStringWhichWillBeUsedWhenGeneratingLabelsForUIButtons - client_id: string - client_secret?: string - issuer_url?: string - auth_url?: string - token_url?: string - mapper_url: JsonnetMapperURL - scope?: string[] - microsoft_tenant?: AzureADTenant - subject_source?: MicrosoftSubjectSource - apple_team_id?: AppleDeveloperTeamID - apple_private_key_id?: ApplePrivateKeyIdentifier - apple_private_key?: ApplePrivateKey - requested_claims?: OpenIDConnectClaims -} + id: string; + provider: Provider; + label?: OptionalStringWhichWillBeUsedWhenGeneratingLabelsForUIButtons; + client_id: string; + client_secret?: string; + issuer_url?: string; + auth_url?: string; + token_url?: string; + mapper_url: JsonnetMapperURL; + scope?: string[]; + microsoft_tenant?: AzureADTenant; + subject_source?: MicrosoftSubjectSource; + apple_team_id?: AppleDeveloperTeamID; + apple_private_key_id?: ApplePrivateKeyIdentifier; + apple_private_key?: ApplePrivateKey; + requested_claims?: OpenIDConnectClaims; +}; export type SelfServiceOIDCProvider1 = { - [k: string]: unknown | undefined + [k: string]: unknown | undefined; } & { - [k: string]: unknown | undefined -} + [k: string]: unknown | undefined; +}; /** * Can be one of github, github-app, gitlab, generic, google, microsoft, discord, slack, facebook, auth0, vk, yandex, apple, spotify, netid, dingtalk, patreon. */ @@ -210,149 +204,163 @@ export type Provider = | "dingtalk" | "patreon" | "linkedin" -export type OptionalStringWhichWillBeUsedWhenGeneratingLabelsForUIButtons = - string + | "lark"; +export type OptionalStringWhichWillBeUsedWhenGeneratingLabelsForUIButtons = string; /** * The URL where the jsonnet source is located for mapping the provider's data to Ory Kratos data. */ -export type JsonnetMapperURL = string +export type JsonnetMapperURL = string; /** * The Azure AD Tenant to use for authentication. */ -export type AzureADTenant = string +export type AzureADTenant = string; /** * Controls which source the subject identifier is taken from by microsoft provider. If set to `userinfo` (the default) then the identifier is taken from the `sub` field of OIDC ID token or data received from `/userinfo` standard OIDC endpoint. If set to `me` then the `id` field of data structure received from `https://graph.microsoft.com/v1.0/me` is taken as an identifier. */ -export type MicrosoftSubjectSource = "userinfo" | "me" +export type MicrosoftSubjectSource = "userinfo" | "me"; /** * Apple Developer Team ID needed for generating a JWT token for client secret */ -export type AppleDeveloperTeamID = string +export type AppleDeveloperTeamID = string; /** * Sign In with Apple Private Key Identifier needed for generating a JWT token for client secret */ -export type ApplePrivateKeyIdentifier = string +export type ApplePrivateKeyIdentifier = string; /** * Sign In with Apple Private Key needed for generating a JWT token for client secret */ -export type ApplePrivateKey = string +export type ApplePrivateKey = string; /** * A list and configuration of OAuth2 and OpenID Connect providers Ory Kratos should integrate with. */ -export type OpenIDConnectAndOAuth2Providers = SelfServiceOIDCProvider[] +export type OpenIDConnectAndOAuth2Providers = SelfServiceOIDCProvider[]; /** * Controls how many records should be purged from one table during database cleanup task */ -export type NumberOfRecordsToCleanInOneIteration = number +export type NumberOfRecordsToCleanInOneIteration = number; /** * Controls the delay time between cleaning each table in one cleanup iteration */ -export type DelayBetweenEachTableCleanups = string +export type DelayBetweenEachTableCleanups = string; /** * Controls how old records do we want to leave */ -export type RemoveRecordsOlderThan = string +export type RemoveRecordsOlderThan = string; /** * DSN is used to specify the database credentials as a connection URI. */ -export type DataSourceName = string +export type DataSourceName = string; /** * You can override certain or all message templates by pointing this key to the path where the templates are located. */ -export type OverrideMessageTemplates = string +export type OverrideMessageTemplates = string; +/** + * Defines how emails will be sent, either through SMTP (default) or HTTP. + */ +export type DeliveryStrategy = "smtp" | "http"; +/** + * This URL will be used to send the emails to. + */ +export type HTTPAddressOfAPIEndpoint = string; +/** + * Define which auth mechanism to use for auth with the HTTP email provider + */ +export type AuthMechanisms = WebHookAuthApiKeyProperties | WebHookAuthBasicAuthProperties; /** * This URI will be used to connect to the SMTP server. Use the scheme smtps for implicit TLS sessions or smtp for explicit StartTLS/cleartext sessions. Please note that TLS is always enforced with certificate trust verification by default for security reasons on both schemes. With the smtp scheme you can use the query parameter (`?disable_starttls=true`) to allow cleartext sessions or (`?disable_starttls=false`) to enforce StartTLS (default behaviour). Additionally, use the query parameter to allow (`?skip_ssl_verify=true`) or disallow (`?skip_ssl_verify=false`) self-signed TLS certificates (default behaviour) on both implicit and explicit TLS sessions. */ -export type SMTPConnectionString = string +export type SMTPConnectionString = string; /** * Path of the client X.509 certificate, in case of certificate based client authentication to the SMTP server. */ -export type SMTPClientCertificatePath = string +export type SMTPClientCertificatePath = string; /** * Path of the client certificate private key, in case of certificate based client authentication to the SMTP server */ -export type SMTPClientPrivateKeyPath = string +export type SMTPClientPrivateKeyPath = string; /** * The recipient of an email will see this as the sender address. */ -export type SMTPSenderAddress = string +export type SMTPSenderAddress = string; /** * The recipient of an email will see this as the sender name. */ -export type SMTPSenderName = string +export type SMTPSenderName = string; /** * Identifier used in the SMTP HELO/EHLO command. Some SMTP relays require a unique identifier. */ -export type SMTPHELOEHLOName = string +export type SMTPHELOEHLOName = string; /** * The recipient of a sms will see this as the sender address. */ -export type SMSSenderAddress = string +export type SMSSenderAddress = string; /** * This URL will be used to connect to the SMS provider. */ -export type HTTPAddressOfAPIEndpoint = string +export type HTTPAddressOfAPIEndpoint1 = string; /** * Define which auth mechanism to use for auth with the SMS provider */ -export type AuthMechanisms = - | WebHookAuthApiKeyProperties - | WebHookAuthBasicAuthProperties +export type AuthMechanisms1 = WebHookAuthApiKeyProperties | WebHookAuthBasicAuthProperties; /** * If set, the login and registration flows will handle the Ory OAuth 2.0 & OpenID `login_challenge` query parameter to serve as an OpenID Connect Provider. This URL should point to Ory Hydra when you are not running on the Ory Network and be left untouched otherwise. */ -export type OAuth20ProviderURL = string +export type OAuth20ProviderURL = string; +/** + * Override the return_to query parameter with the OAuth2 provider request URL when perfoming an OAuth2 login flow. + */ +export type PersistOAuth2RequestBetweenFlows = boolean; /** * Disable request logging for /health/alive and /health/ready endpoints */ -export type DisableHealthEndpointsRequestLogging = boolean +export type DisableHealthEndpointsRequestLogging = boolean; /** * The URL where the admin endpoint is exposed at. */ -export type AdminBaseURL = string +export type AdminBaseURL = string; /** * The host (interface) kratos' admin endpoint listens on. */ -export type AdminHost = string +export type AdminHost = string; /** * The port kratos' admin endpoint listens on. */ -export type AdminPort = number -export type PrivateKeyPEM = TlsxSource -export type PathToPEMEncodedFle = string +export type AdminPort = number; +export type PrivateKeyPEM = TlsxSource; +export type PathToPEMEncodedFle = string; /** * The base64 string of the PEM-encoded file content. Can be generated using for example `base64 -i path/to/file.pem`. */ -export type Base64EncodedInline = string -export type TLSCertificatePEM = TlsxSource +export type Base64EncodedInline = string; +export type TLSCertificatePEM = TlsxSource; /** * Disable request logging for /health/alive and /health/ready endpoints */ -export type DisableHealthEndpointsRequestLogging1 = boolean +export type DisableHealthEndpointsRequestLogging1 = boolean; /** * The URL where the endpoint is exposed at. This domain is used to generate redirects, form URLs, and more. */ -export type BaseURL = string +export type BaseURL = string; /** * The host (interface) kratos' public endpoint listens on. */ -export type PublicHost = string +export type PublicHost = string; /** * The port kratos' public endpoint listens on. */ -export type PublicPort = number +export type PublicPort = number; /** * If set will leak sensitive values (e.g. emails) in the logs. */ -export type LeakSensitiveLogValues = boolean +export type LeakSensitiveLogValues = boolean; /** * Text to use, when redacting sensitive log value. */ -export type SensitiveLogValueRedactionText = string +export type SensitiveLogValueRedactionText = string; /** * This Identity Schema will be used as the default for self-service flows. Its ID needs to exist in the "schemas" list. */ -export type TheDefaultIdentitySchema = string +export type TheDefaultIdentitySchema = string; /** * Note that identities that used the "default_schema_url" field in older kratos versions will be corrupted unless you specify their schema url with the id "default" in this list. * @@ -360,191 +368,193 @@ export type TheDefaultIdentitySchema = string */ export type AllJSONSchemasForIdentityTraits = [ { - id: TheSchemaSID - url: JSONSchemaURLForIdentityTraitsSchema - [k: string]: unknown | undefined + id: TheSchemaSID; + url: JSONSchemaURLForIdentityTraitsSchema; + [k: string]: unknown | undefined; }, ...{ - id: TheSchemaSID - url: JSONSchemaURLForIdentityTraitsSchema - [k: string]: unknown | undefined - }[], -] -export type TheSchemaSID = string + id: TheSchemaSID; + url: JSONSchemaURLForIdentityTraitsSchema; + [k: string]: unknown | undefined; + }[] +]; +export type TheSchemaSID = string; /** * URL for JSON Schema which describes a identity's traits. Can be a file path, a https URL, or a base64 encoded string. */ -export type JSONSchemaURLForIdentityTraitsSchema = string +export type JSONSchemaURLForIdentityTraitsSchema = string; /** * The first secret in the array is used for signing and encrypting things while all other keys are used to verify and decrypt older things that were signed with that old secret. */ -export type DefaultEncryptionSigningSecrets = string[] +export type DefaultEncryptionSigningSecrets = string[]; /** * The first secret in the array is used for encrypting cookies while all other keys are used to decrypt older cookies that were signed with that old secret. */ -export type SigningKeysForCookies = string[] +export type SigningKeysForCookies = string[]; /** * The first secret in the array is used for encryption data while all other keys are used to decrypt older data that were signed with. * * @minItems 1 */ -export type SecretsToUseForEncryptionByCipher = [string, ...string[]] +export type SecretsToUseForEncryptionByCipher = [string, ...string[]]; /** * One of the values: argon2, bcrypt. * Any other hashes will be migrated to the set algorithm once an identity authenticates using their password. */ -export type PasswordHashingAlgorithm = "argon2" | "bcrypt" +export type PasswordHashingAlgorithm = "argon2" | "bcrypt"; /** * One of the values: noop, aes, xchacha20-poly1305 */ -export type CipheringAlgorithm = "noop" | "aes" | "xchacha20-poly1305" +export type CipheringAlgorithm = "noop" | "aes" | "xchacha20-poly1305"; /** * Sets the cookie domain for session and CSRF cookies. Useful when dealing with subdomains. Use with care! */ -export type HTTPCookieDomain = string +export type HTTPCookieDomain = string; /** * Sets the session and CSRF cookie path. Use with care! */ -export type HTTPCookiePath = string +export type HTTPCookiePath = string; /** * Sets the session and CSRF cookie SameSite. */ -export type HTTPCookieSameSiteConfiguration = "Strict" | "Lax" | "None" +export type HTTPCookieSameSiteConfiguration = "Strict" | "Lax" | "None"; /** * Defines how long a session is active. Once that lifespan has been reached, the user needs to sign in again. */ -export type SessionLifespan = string +export type SessionLifespan = string; /** * Sets the session cookie domain. Useful when dealing with subdomains. Use with care! Overrides `cookies.domain`. */ -export type SessionCookieDomain = string +export type SessionCookieDomain = string; /** * Sets the session cookie name. Use with care! */ -export type SessionCookieName = string +export type SessionCookieName = string; /** * If set to true will persist the cookie in the end-user's browser using the `max-age` parameter which is set to the `session.lifespan` value. Persistent cookies are not deleted when the browser is closed (e.g. on reboot or alt+f4). This option affects the Ory OAuth2 and OpenID Provider's remember feature as well. */ -export type MakeSessionCookiePersistent = boolean +export type MakeSessionCookiePersistent = boolean; /** * Sets the session cookie path. Use with care! Overrides `cookies.path`. */ -export type SessionCookiePath = string +export type SessionCookiePath = string; /** * Sets the session cookie SameSite. Overrides `cookies.same_site`. */ -export type SessionCookieSameSiteConfiguration = "Strict" | "Lax" | "None" +export type SessionCookieSameSiteConfiguration = "Strict" | "Lax" | "None"; /** * Sets when a session can be extended. Settings this value to `24h` will prevent the session from being extended before until 24 hours before it expires. This setting prevents excessive writes to the database. We highly recommend setting this value. */ -export type EarliestPossibleSessionExtension = string +export type EarliestPossibleSessionExtension = string; /** * SemVer according to https://semver.org/ prefixed with `v` as in our releases. */ -export type TheKratosVersionThisConfigIsWrittenFor = string +export type TheKratosVersionThisConfigIsWrittenFor = string; /** * The port the courier's metrics endpoint listens on (0/disabled by default). This is a CLI flag and environment variable and can not be set using the config file. */ -export type MetricsPort = number +export type MetricsPort = number; /** * Disallow all outgoing HTTP calls to private IP ranges. This feature can help protect against SSRF attacks. */ -export type DisallowPrivateIPRanges = boolean +export type DisallowPrivateIPRanges = boolean; /** * Allows the given URLs to be called despite them being in the private IP range. URLs need to have an exact and case-sensitive match to be excempt. */ -export type AddExemptURLsToPrivateIPRanges = string[] +export type AddExemptURLsToPrivateIPRanges = string[]; /** * If enabled allows Ory Sessions to be cached. Only effective in the Ory Network. */ -export type EnableOrySessionsCaching = boolean +export type EnableOrySessionsCaching = boolean; export interface OryKratosConfiguration2 { selfservice: { - default_browser_return_url: RedirectBrowsersToSetURLPerDefault - allowed_return_urls?: AllowedReturnToURLs + default_browser_return_url: RedirectBrowsersToSetURLPerDefault; + allowed_return_urls?: AllowedReturnToURLs; flows?: { settings?: { - ui_url?: URLOfTheSettingsPage - lifespan?: string - privileged_session_max_age?: string - required_aal?: RequiredAuthenticatorAssuranceLevel - after?: SelfServiceAfterSettings - before?: SelfServiceBeforeSettings - } + ui_url?: URLOfTheSettingsPage; + lifespan?: string; + privileged_session_max_age?: string; + required_aal?: RequiredAuthenticatorAssuranceLevel; + after?: SelfServiceAfterSettings; + before?: SelfServiceBeforeSettings; + }; logout?: { after?: { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - } - } + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + }; + }; registration?: { - enabled?: EnableUserRegistration - ui_url?: RegistrationUIURL - lifespan?: string - before?: SelfServiceBeforeRegistration - after?: SelfServiceAfterRegistration - } + enabled?: EnableUserRegistration; + ui_url?: RegistrationUIURL; + lifespan?: string; + before?: SelfServiceBeforeRegistration; + after?: SelfServiceAfterRegistration; + }; login?: { - ui_url?: LoginUIURL - lifespan?: string - before?: SelfServiceBeforeLogin - after?: SelfServiceAfterLogin - } - verification?: EmailAndPhoneVerificationAndAccountActivationConfiguration - recovery?: AccountRecoveryConfiguration + ui_url?: LoginUIURL; + lifespan?: string; + before?: SelfServiceBeforeLogin; + after?: SelfServiceAfterLogin; + }; + verification?: EmailAndPhoneVerificationAndAccountActivationConfiguration; + recovery?: AccountRecoveryConfiguration; error?: { - ui_url?: OryKratosErrorUIURL - } - } + ui_url?: OryKratosErrorUIURL; + }; + }; methods?: { profile?: { - enabled?: EnablesProfileManagementMethod - } + enabled?: EnablesProfileManagementMethod; + }; link?: { - enabled?: EnablesLinkMethod - config?: LinkConfiguration - } + enabled?: EnablesLinkMethod; + config?: LinkConfiguration; + }; code?: { - enabled?: EnablesCodeMethod - config?: CodeConfiguration - } + login_enabled?: EnablesLoginWithCodeMethod; + registration_enabled?: EnablesRegistrationWithCodeMethod; + enabled?: EnablesCodeMethod; + config?: CodeConfiguration; + }; password?: { - enabled?: EnablesUsernameEmailAndPasswordMethod - config?: PasswordConfiguration - } + enabled?: EnablesUsernameEmailAndPasswordMethod; + config?: PasswordConfiguration; + }; totp?: { - enabled?: EnablesTheTOTPMethod - config?: TOTPConfiguration - } + enabled?: EnablesTheTOTPMethod; + config?: TOTPConfiguration; + }; lookup_secret?: { - enabled?: EnablesTheLookupSecretMethod - } + enabled?: EnablesTheLookupSecretMethod; + }; webauthn?: { - enabled?: EnablesTheWebAuthnMethod - config?: WebAuthnConfiguration - } - oidc?: SpecifyOpenIDConnectAndOAuth2Configuration - } - } - database?: DatabaseRelatedConfiguration - dsn: DataSourceName - courier?: CourierConfiguration - oauth2_provider?: OAuth2ProviderConfiguration + enabled?: EnablesTheWebAuthnMethod; + config?: WebAuthnConfiguration; + }; + oidc?: SpecifyOpenIDConnectAndOAuth2Configuration; + }; + }; + database?: DatabaseRelatedConfiguration; + dsn: DataSourceName; + courier?: CourierConfiguration; + oauth2_provider?: OAuth2ProviderConfiguration; serve?: { admin?: { request_log?: { - disable_for_health?: DisableHealthEndpointsRequestLogging - } - base_url?: AdminBaseURL - host?: AdminHost - port?: AdminPort - socket?: Socket - tls?: HTTPS - } + disable_for_health?: DisableHealthEndpointsRequestLogging; + }; + base_url?: AdminBaseURL; + host?: AdminHost; + port?: AdminPort; + socket?: Socket; + tls?: HTTPS; + }; public?: { request_log?: { - disable_for_health?: DisableHealthEndpointsRequestLogging1 - } + disable_for_health?: DisableHealthEndpointsRequestLogging1; + }; /** * Configures Cross Origin Resource Sharing for public endpoints. */ @@ -552,250 +562,232 @@ export interface OryKratosConfiguration2 { /** * Sets whether CORS is enabled. */ - enabled?: boolean + enabled?: boolean; /** * A list of origins a cross-domain request can be executed from. If the special * value is present in the list, all origins will be allowed. An origin may contain a wildcard (*) to replace 0 or more characters (i.e.: http://*.domain.com). Only one wildcard can be used per origin. */ - allowed_origins?: ((string | "*") & string)[] + allowed_origins?: ((string | "*") & string)[]; /** * A list of HTTP methods the user agent is allowed to use with cross-domain requests. */ - allowed_methods?: ( - | "POST" - | "GET" - | "PUT" - | "PATCH" - | "DELETE" - | "CONNECT" - | "HEAD" - | "OPTIONS" - | "TRACE" - )[] + allowed_methods?: ("POST" | "GET" | "PUT" | "PATCH" | "DELETE" | "CONNECT" | "HEAD" | "OPTIONS" | "TRACE")[]; /** * A list of non simple headers the client is allowed to use with cross-domain requests. */ - allowed_headers?: string[] + allowed_headers?: string[]; /** * Sets which headers are safe to expose to the API of a CORS API specification. */ - exposed_headers?: string[] + exposed_headers?: string[]; /** * Sets whether the request can include user credentials like cookies, HTTP authentication or client side SSL certificates. */ - allow_credentials?: boolean + allow_credentials?: boolean; /** * TODO */ - options_passthrough?: boolean + options_passthrough?: boolean; /** * Sets how long (in seconds) the results of a preflight request can be cached. If set to 0, every request is preceded by a preflight request. */ - max_age?: number + max_age?: number; /** * Adds additional log output to debug server side CORS issues. */ - debug?: boolean - } - base_url?: BaseURL - host?: PublicHost - port?: PublicPort - socket?: Socket - tls?: HTTPS - } - } - tracing?: OryTracingConfig - log?: Log + debug?: boolean; + }; + base_url?: BaseURL; + host?: PublicHost; + port?: PublicPort; + socket?: Socket; + tls?: HTTPS; + }; + }; + tracing?: OryTracingConfig; + log?: Log; identity: { - default_schema_id?: TheDefaultIdentitySchema - schemas: AllJSONSchemasForIdentityTraits - } + default_schema_id?: TheDefaultIdentitySchema; + schemas: AllJSONSchemasForIdentityTraits; + }; secrets?: { - default?: DefaultEncryptionSigningSecrets - cookie?: SigningKeysForCookies - cipher?: SecretsToUseForEncryptionByCipher - } - hashers?: HashingAlgorithmConfiguration - ciphers?: CipherAlgorithmConfiguration - cookies?: HTTPCookieConfiguration + default?: DefaultEncryptionSigningSecrets; + cookie?: SigningKeysForCookies; + cipher?: SecretsToUseForEncryptionByCipher; + }; + hashers?: HashingAlgorithmConfiguration; + ciphers?: CipherAlgorithmConfiguration; + cookies?: HTTPCookieConfiguration; session?: { - whoami?: WhoAmIToSessionSettings - lifespan?: SessionLifespan + whoami?: WhoAmIToSessionSettings; + lifespan?: SessionLifespan; cookie?: { - domain?: SessionCookieDomain - name?: SessionCookieName - persistent?: MakeSessionCookiePersistent - path?: SessionCookiePath - same_site?: SessionCookieSameSiteConfiguration - } - earliest_possible_extend?: EarliestPossibleSessionExtension - } - version?: TheKratosVersionThisConfigIsWrittenFor - dev?: boolean - help?: boolean + domain?: SessionCookieDomain; + name?: SessionCookieName; + persistent?: MakeSessionCookiePersistent; + path?: SessionCookiePath; + same_site?: SessionCookieSameSiteConfiguration; + }; + earliest_possible_extend?: EarliestPossibleSessionExtension; + }; + version?: TheKratosVersionThisConfigIsWrittenFor; + dev?: boolean; + help?: boolean; /** * This is a CLI flag and environment variable and can not be set using the config file. */ - "sqa-opt-out"?: boolean + "sqa-opt-out"?: boolean; /** * This is a CLI flag and environment variable and can not be set using the config file. */ - "watch-courier"?: boolean - "expose-metrics-port"?: MetricsPort + "watch-courier"?: boolean; + "expose-metrics-port"?: MetricsPort; /** * This is a CLI flag and environment variable and can not be set using the config file. */ - config?: string[] - clients?: GlobalOutgoingNetworkSettings - feature_flags?: FeatureFlags + config?: string[]; + clients?: GlobalOutgoingNetworkSettings; + feature_flags?: FeatureFlags; } export interface SelfServiceAfterSettings { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - password?: SelfServiceAfterSettingsMethod - profile?: SelfServiceAfterSettingsMethod - hooks?: SelfServiceHooks + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + password?: SelfServiceAfterSettingsMethod; + profile?: SelfServiceAfterSettingsMethod; + hooks?: SelfServiceHooks; } export interface SelfServiceAfterSettingsMethod { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - hooks?: SelfServiceWebHook[] + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + hooks?: SelfServiceWebHook[]; } export interface SelfServiceWebHook { - hook: "web_hook" - config: WebHookConfiguration + hook: "web_hook"; + config: WebHookConfiguration; } export interface SelfServiceBeforeSettings { - hooks?: SelfServiceHooks + hooks?: SelfServiceHooks; } export interface SelfServiceBeforeRegistration { - hooks?: SelfServiceHooks + hooks?: SelfServiceHooks; } export interface SelfServiceAfterRegistration { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - password?: SelfServiceAfterRegistrationMethod - webauthn?: SelfServiceAfterRegistrationMethod - oidc?: SelfServiceAfterRegistrationMethod - hooks?: SelfServiceHooks + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + password?: SelfServiceAfterRegistrationMethod; + webauthn?: SelfServiceAfterRegistrationMethod; + oidc?: SelfServiceAfterRegistrationMethod; + code?: SelfServiceAfterRegistrationMethod; + hooks?: SelfServiceHooks; } export interface SelfServiceAfterRegistrationMethod { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - hooks?: (SelfServiceSessionIssuerHook | SelfServiceWebHook)[] + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + hooks?: (SelfServiceSessionIssuerHook | SelfServiceWebHook | SelfServiceShowVerificationUIHook)[]; } export interface SelfServiceSessionIssuerHook { - hook: "session" + hook: "session"; +} +export interface SelfServiceShowVerificationUIHook { + hook: "show_verification_ui"; } export interface SelfServiceBeforeLogin { - hooks?: SelfServiceHooks + hooks?: SelfServiceHooks; } export interface SelfServiceAfterLogin { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - password?: SelfServiceAfterDefaultLoginMethod - webauthn?: SelfServiceAfterDefaultLoginMethod - oidc?: SelfServiceAfterOIDCLoginMethod - hooks?: ( - | SelfServiceWebHook - | SelfServiceSessionRevokerHook - | SelfServiceRequireVerifiedAddressHook - )[] + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + password?: SelfServiceAfterDefaultLoginMethod; + webauthn?: SelfServiceAfterDefaultLoginMethod; + oidc?: SelfServiceAfterOIDCLoginMethod; + hooks?: (SelfServiceWebHook | SelfServiceSessionRevokerHook | SelfServiceRequireVerifiedAddressHook)[]; } export interface SelfServiceAfterDefaultLoginMethod { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - hooks?: ( - | SelfServiceSessionRevokerHook - | SelfServiceRequireVerifiedAddressHook - | SelfServiceWebHook - )[] + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + hooks?: (SelfServiceSessionRevokerHook | SelfServiceRequireVerifiedAddressHook | SelfServiceWebHook)[]; } export interface SelfServiceSessionRevokerHook { - hook: "revoke_active_sessions" + hook: "revoke_active_sessions"; } export interface SelfServiceRequireVerifiedAddressHook { - hook: "require_verified_address" + hook: "require_verified_address"; } export interface SelfServiceAfterOIDCLoginMethod { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - hooks?: ( - | SelfServiceSessionRevokerHook - | SelfServiceWebHook - | SelfServiceRequireVerifiedAddressHook - )[] + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + hooks?: (SelfServiceSessionRevokerHook | SelfServiceWebHook | SelfServiceRequireVerifiedAddressHook)[]; } export interface EmailAndPhoneVerificationAndAccountActivationConfiguration { - enabled?: EnableEmailPhoneVerification - ui_url?: VerifyUIURL - after?: SelfServiceAfterVerification - lifespan?: SelfServiceVerificationRequestLifespan - before?: SelfServiceBeforeVerification - use?: VerificationStrategy - notify_unknown_recipients?: NotifyUnknownRecipients + enabled?: EnableEmailPhoneVerification; + ui_url?: VerifyUIURL; + after?: SelfServiceAfterVerification; + lifespan?: SelfServiceVerificationRequestLifespan; + before?: SelfServiceBeforeVerification; + use?: VerificationStrategy; + notify_unknown_recipients?: NotifyUnknownRecipients; } export interface SelfServiceAfterVerification { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - hooks?: SelfServiceHooks + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + hooks?: SelfServiceHooks; } export interface SelfServiceBeforeVerification { - hooks?: SelfServiceHooks + hooks?: SelfServiceHooks; } export interface AccountRecoveryConfiguration { - enabled?: EnableAccountRecovery - ui_url?: RecoveryUIURL - after?: SelfServiceAfterRecovery - lifespan?: SelfServiceRecoveryRequestLifespan - before?: SelfServiceBeforeRecovery - use?: RecoveryStrategy - notify_unknown_recipients?: NotifyUnknownRecipients1 + enabled?: EnableAccountRecovery; + ui_url?: RecoveryUIURL; + after?: SelfServiceAfterRecovery; + lifespan?: SelfServiceRecoveryRequestLifespan; + before?: SelfServiceBeforeRecovery; + use?: RecoveryStrategy; + notify_unknown_recipients?: NotifyUnknownRecipients1; } export interface SelfServiceAfterRecovery { - default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - hooks?: SelfServiceAfterRecoveryHooks + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault; + hooks?: SelfServiceAfterRecoveryHooks; } export interface SelfServiceBeforeRecovery { - hooks?: SelfServiceHooks + hooks?: SelfServiceHooks; } /** * Additional configuration for the link strategy. */ export interface LinkConfiguration { - base_url?: OverrideTheBaseURLWhichShouldBeUsedAsTheBaseForRecoveryAndVerificationLinks - lifespan?: HowLongALinkIsValidFor - [k: string]: unknown | undefined + base_url?: OverrideTheBaseURLWhichShouldBeUsedAsTheBaseForRecoveryAndVerificationLinks; + lifespan?: HowLongALinkIsValidFor; + [k: string]: unknown | undefined; } /** * Additional configuration for the code strategy. */ export interface CodeConfiguration { - lifespan?: HowLongACodeIsValidFor - [k: string]: unknown | undefined + lifespan?: HowLongACodeIsValidFor; + [k: string]: unknown | undefined; } /** * Define how passwords are validated. */ export interface PasswordConfiguration { - haveibeenpwned_host?: CustomHaveibeenpwnedHost - haveibeenpwned_enabled?: EnableTheHaveIBeenPwnedAPI - max_breaches?: AllowPasswordBreaches - ignore_network_errors?: IgnoreLookupNetworkErrors - min_password_length?: MinimumPasswordLength - identifier_similarity_check_enabled?: EnablePasswordIdentifierSimilarityCheck + haveibeenpwned_host?: CustomHaveibeenpwnedHost; + haveibeenpwned_enabled?: EnableTheHaveIBeenPwnedAPI; + max_breaches?: AllowPasswordBreaches; + ignore_network_errors?: IgnoreLookupNetworkErrors; + min_password_length?: MinimumPasswordLength; + identifier_similarity_check_enabled?: EnablePasswordIdentifierSimilarityCheck; } export interface TOTPConfiguration { - issuer?: TOTPIssuer + issuer?: TOTPIssuer; } export interface WebAuthnConfiguration { - passwordless?: UseForPasswordlessFlows - rp?: RelyingPartyRPConfig + passwordless?: UseForPasswordlessFlows; + rp?: RelyingPartyRPConfig; } export interface RelyingPartyRPConfig { - display_name: RelyingPartyDisplayName - id: RelyingPartyIdentifier - origin?: RelyingPartyOrigin - icon?: RelyingPartyIcon - [k: string]: unknown | undefined + display_name: RelyingPartyDisplayName; + id: RelyingPartyIdentifier; + origin?: RelyingPartyOrigin; + icon?: RelyingPartyIcon; + [k: string]: unknown | undefined; } export interface SpecifyOpenIDConnectAndOAuth2Configuration { - enabled?: EnablesOpenIDConnectMethod + enabled?: EnablesOpenIDConnectMethod; config?: { - base_redirect_uri?: BaseURLForOAuth2RedirectURIs - providers?: OpenIDConnectAndOAuth2Providers - } + base_redirect_uri?: BaseURLForOAuth2RedirectURIs; + providers?: OpenIDConnectAndOAuth2Providers; + }; } /** * The OpenID Connect claims and optionally their properties which should be included in the id_token or returned from the UserInfo Endpoint. @@ -814,100 +806,157 @@ export interface OpenIDConnectClaims { /** * Indicates whether the Claim being requested is an Essential Claim. */ - essential?: boolean + essential?: boolean; /** * Requests that the Claim be returned with a particular value. */ value?: { - [k: string]: unknown | undefined - } + [k: string]: unknown | undefined; + }; /** * Requests that the Claim be returned with one of a set of values, with the values appearing in order of preference. */ values?: { - [k: string]: unknown | undefined - }[] - } - } + [k: string]: unknown | undefined; + }[]; + }; + }; } /** * Miscellaneous settings used in database related tasks (cleanup, etc.) */ export interface DatabaseRelatedConfiguration { - cleanup?: DatabaseCleanupSettings + cleanup?: DatabaseCleanupSettings; } /** * Settings that controls how the database cleanup process is configured (delays, batch size, etc.) */ export interface DatabaseCleanupSettings { - batch_size?: NumberOfRecordsToCleanInOneIteration - sleep?: DelaysBetweenVariousDatabaseCleanupPhases - older_than?: RemoveRecordsOlderThan - [k: string]: unknown | undefined + batch_size?: NumberOfRecordsToCleanInOneIteration; + sleep?: DelaysBetweenVariousDatabaseCleanupPhases; + older_than?: RemoveRecordsOlderThan; + [k: string]: unknown | undefined; } /** * Configures delays between each step of the cleanup process. It is useful to tune the process so it will be efficient and performant. */ export interface DelaysBetweenVariousDatabaseCleanupPhases { - tables?: DelayBetweenEachTableCleanups - [k: string]: unknown | undefined + tables?: DelayBetweenEachTableCleanups; + [k: string]: unknown | undefined; } /** * The courier is responsible for sending and delivering messages over email, sms, and other means. */ export interface CourierConfiguration { templates?: { - recovery?: CourierTemplates - recovery_code?: CourierTemplates - verification?: CourierTemplates - verification_code?: CourierTemplates - } - template_override_path?: OverrideMessageTemplates + recovery?: CourierTemplates; + recovery_code?: CourierTemplates; + verification?: CourierTemplates; + verification_code?: CourierTemplates; + }; + template_override_path?: OverrideMessageTemplates; /** * Defines the maximum number of times the sending of a message is retried after it failed before it is marked as abandoned */ - message_retries?: number - smtp: SMTPConfiguration - sms?: SMSSenderConfiguration + message_retries?: number; + delivery_strategy?: DeliveryStrategy; + http?: HTTPConfiguration; + smtp: SMTPConfiguration; + sms?: SMSSenderConfiguration; } export interface CourierTemplates { invalid?: { - email: EmailCourierTemplate - } + email: EmailCourierTemplate; + }; valid?: { - email: EmailCourierTemplate - } + email: EmailCourierTemplate; + }; } export interface EmailCourierTemplate { body?: { /** * The fallback template for email clients that do not support html. */ - plaintext?: string + plaintext?: string; /** * The default template used for sending out emails. The template can contain HTML */ - html?: string - } - subject?: string + html?: string; + }; + subject?: string; +} +/** + * Configures outgoing emails using HTTP. + */ +export interface HTTPConfiguration { + request_config?: HttpRequestConfig; +} +export interface HttpRequestConfig { + url?: HTTPAddressOfAPIEndpoint; + /** + * The HTTP method to use (GET, POST, etc). Defaults to POST. + */ + method?: string; + /** + * The HTTP headers that must be applied to request + */ + headers?: { + [k: string]: string | undefined; + }; + /** + * URI pointing to the jsonnet template used for payload generation. Only used for those HTTP methods, which support HTTP body payloads + */ + body?: string; + auth?: AuthMechanisms; + additionalProperties?: false; +} +export interface WebHookAuthApiKeyProperties { + type: "api_key"; + config: { + /** + * The name of the api key + */ + name: string; + /** + * The value of the api key + */ + value: string; + /** + * How the api key should be transferred + */ + in: "header" | "cookie"; + }; +} +export interface WebHookAuthBasicAuthProperties { + type: "basic_auth"; + config: { + /** + * user name for basic auth + */ + user: string; + /** + * password for basic auth + */ + password: string; + }; } /** * Configures outgoing emails using the SMTP protocol. */ export interface SMTPConfiguration { - connection_uri: SMTPConnectionString - client_cert_path?: SMTPClientCertificatePath - client_key_path?: SMTPClientPrivateKeyPath - from_address?: SMTPSenderAddress - from_name?: SMTPSenderName - headers?: SMTPHeaders - local_name?: SMTPHELOEHLOName + connection_uri: SMTPConnectionString; + client_cert_path?: SMTPClientCertificatePath; + client_key_path?: SMTPClientPrivateKeyPath; + from_address?: SMTPSenderAddress; + from_name?: SMTPSenderName; + headers?: SMTPHeaders; + local_name?: SMTPHELOEHLOName; } /** * These headers will be passed in the SMTP conversation -- e.g. when using the AWS SES SMTP interface for cross-account sending. */ export interface SMTPHeaders { - [k: string]: string | undefined + [k: string]: string | undefined; } /** * Configures outgoing sms messages using HTTP protocol with generic SMS provider @@ -916,68 +965,38 @@ export interface SMSSenderConfiguration { /** * Determines if SMS functionality is enabled */ - enabled?: boolean - from?: SMSSenderAddress + enabled?: boolean; + from?: SMSSenderAddress; request_config?: { - url: HTTPAddressOfAPIEndpoint + url: HTTPAddressOfAPIEndpoint1; /** * The HTTP method to use (GET, POST, etc). */ - method: string + method: string; /** * The HTTP headers that must be applied to request */ headers?: { - [k: string]: string | undefined - } + [k: string]: string | undefined; + }; /** * URI pointing to the jsonnet template used for payload generation. Only used for those HTTP methods, which support HTTP body payloads */ - body?: string - auth?: AuthMechanisms - additionalProperties?: false - } -} -export interface WebHookAuthApiKeyProperties { - type: "api_key" - config: { - /** - * The name of the api key - */ - name: string - /** - * The value of the api key - */ - value: string - /** - * How the api key should be transferred - */ - in: "header" | "cookie" - } -} -export interface WebHookAuthBasicAuthProperties { - type: "basic_auth" - config: { - /** - * user name for basic auth - */ - user: string - /** - * password for basic auth - */ - password: string - } + body?: string; + auth?: AuthMechanisms1; + additionalProperties?: false; + }; } export interface OAuth2ProviderConfiguration { - url?: OAuth20ProviderURL - headers?: HTTPRequestHeaders - override_return_to?: boolean + url?: OAuth20ProviderURL; + headers?: HTTPRequestHeaders; + override_return_to?: PersistOAuth2RequestBetweenFlows; } /** * These headers will be passed in HTTP request to the OAuth2 Provider. */ export interface HTTPRequestHeaders { - [k: string]: string | undefined + [k: string]: string | undefined; } /** * Sets the permissions of the unix socket @@ -986,26 +1005,26 @@ export interface Socket { /** * Owner of unix socket. If empty, the owner will be the user running Kratos. */ - owner?: string + owner?: string; /** * Group of unix socket. If empty, the group will be the primary group of the user running Kratos. */ - group?: string + group?: string; /** * Mode of unix socket in numeric form */ - mode?: number + mode?: number; } /** * Configure HTTP over TLS (HTTPS). All options can also be set using environment variables by replacing dots (`.`) with underscores (`_`) and uppercasing the key. For example, `some.prefix.tls.key.path` becomes `export SOME_PREFIX_TLS_KEY_PATH`. If all keys are left undefined, TLS will be disabled. */ export interface HTTPS { - key?: PrivateKeyPEM - cert?: TLSCertificatePEM + key?: PrivateKeyPEM; + cert?: TLSCertificatePEM; } export interface TlsxSource { - path?: PathToPEMEncodedFle - base64?: Base64EncodedInline + path?: PathToPEMEncodedFle; + base64?: Base64EncodedInline; } /** * Configure distributed tracing using OpenTelemetry @@ -1014,11 +1033,11 @@ export interface OryTracingConfig { /** * Set this to the tracing backend you wish to use. Supports Jaeger, Zipkin, and OTEL. */ - provider?: "jaeger" | "otel" | "zipkin" + provider?: "jaeger" | "otel" | "zipkin"; /** * Specifies the service name to use on the tracer. */ - service_name?: string + service_name?: string; providers?: { /** * Configures the jaeger tracing backend. @@ -1027,23 +1046,18 @@ export interface OryTracingConfig { /** * The address of the jaeger-agent where spans should be sent to. */ - local_agent_address?: ( - | IPv6AddressAndPort - | IPv4AddressAndPort - | HostnameAndPort - ) & - string + local_agent_address?: (IPv6AddressAndPort | IPv4AddressAndPort | HostnameAndPort) & string; sampling?: { /** * The address of jaeger-agent's HTTP sampling server */ - server_url?: string + server_url?: string; /** * Trace Id ratio sample */ - trace_id_ratio?: number - } - } + trace_id_ratio?: number; + }; + }; /** * Configures the zipkin tracing backend. */ @@ -1051,14 +1065,14 @@ export interface OryTracingConfig { /** * The address of the Zipkin server where spans should be sent to. */ - server_url?: string + server_url?: string; sampling?: { /** * Sampling ratio for spans. */ - sampling_ratio?: number - } - } + sampling_ratio?: number; + }; + }; /** * Configures the OTLP tracing backend. */ @@ -1066,42 +1080,37 @@ export interface OryTracingConfig { /** * The endpoint of the OTLP exporter (HTTP) where spans should be sent to. */ - server_url?: ( - | IPv6AddressAndPort1 - | IPv4AddressAndPort1 - | HostnameAndPort1 - ) & - string + server_url?: (IPv6AddressAndPort1 | IPv4AddressAndPort1 | HostnameAndPort1) & string; /** * Will use HTTP if set to true; defaults to HTTPS. */ - insecure?: boolean + insecure?: boolean; sampling?: { /** * Sampling ratio for spans. */ - sampling_ratio?: number - } - } - } + sampling_ratio?: number; + }; + }; + }; } export interface IPv6AddressAndPort { - [k: string]: unknown | undefined + [k: string]: unknown | undefined; } export interface IPv4AddressAndPort { - [k: string]: unknown | undefined + [k: string]: unknown | undefined; } export interface HostnameAndPort { - [k: string]: unknown | undefined + [k: string]: unknown | undefined; } export interface IPv6AddressAndPort1 { - [k: string]: unknown | undefined + [k: string]: unknown | undefined; } export interface IPv4AddressAndPort1 { - [k: string]: unknown | undefined + [k: string]: unknown | undefined; } export interface HostnameAndPort1 { - [k: string]: unknown | undefined + [k: string]: unknown | undefined; } /** * Configure logging using the following options. Logging will always be sent to stdout and stderr. @@ -1110,77 +1119,77 @@ export interface Log { /** * Debug enables stack traces on errors. Can also be set using environment variable LOG_LEVEL. */ - level?: "trace" | "debug" | "info" | "warning" | "error" | "fatal" | "panic" - leak_sensitive_values?: LeakSensitiveLogValues - redaction_text?: SensitiveLogValueRedactionText + level?: "trace" | "debug" | "info" | "warning" | "error" | "fatal" | "panic"; + leak_sensitive_values?: LeakSensitiveLogValues; + redaction_text?: SensitiveLogValueRedactionText; /** * The log format can either be text or JSON. */ - format?: "json" | "text" + format?: "json" | "text"; } export interface HashingAlgorithmConfiguration { - algorithm?: PasswordHashingAlgorithm - argon2?: ConfigurationForTheArgon2IdHasher - bcrypt?: ConfigurationForTheBcryptHasherMinimumIs4WhenDevFlagIsUsedAnd12Otherwise + algorithm?: PasswordHashingAlgorithm; + argon2?: ConfigurationForTheArgon2IdHasher; + bcrypt?: ConfigurationForTheBcryptHasherMinimumIs4WhenDevFlagIsUsedAnd12Otherwise; } export interface ConfigurationForTheArgon2IdHasher { - memory?: string - iterations?: number + memory?: string; + iterations?: number; /** * Number of parallel workers, defaults to 2*runtime.NumCPU(). */ - parallelism?: number - salt_length?: number - key_length?: number + parallelism?: number; + salt_length?: number; + key_length?: number; /** * The time a hashing operation (~login latency) should take. */ - expected_duration?: string + expected_duration?: string; /** * The standard deviation expected for hashing operations. If this value is exceeded you will be warned in the logs to adjust the parameters. */ - expected_deviation?: string + expected_deviation?: string; /** * The memory dedicated for Kratos. As password hashing is very resource intense, Kratos will monitor the memory consumption and warn about high values. */ - dedicated_memory?: string + dedicated_memory?: string; } export interface ConfigurationForTheBcryptHasherMinimumIs4WhenDevFlagIsUsedAnd12Otherwise { - cost: number + cost: number; } export interface CipherAlgorithmConfiguration { - algorithm?: CipheringAlgorithm - [k: string]: unknown | undefined + algorithm?: CipheringAlgorithm; + [k: string]: unknown | undefined; } /** * Configure the HTTP Cookies. Applies to both CSRF and session cookies. */ export interface HTTPCookieConfiguration { - domain?: HTTPCookieDomain - path?: HTTPCookiePath - same_site?: HTTPCookieSameSiteConfiguration + domain?: HTTPCookieDomain; + path?: HTTPCookiePath; + same_site?: HTTPCookieSameSiteConfiguration; } /** * Control how the `/sessions/whoami` endpoint is behaving. */ export interface WhoAmIToSessionSettings { - required_aal?: RequiredAuthenticatorAssuranceLevel + required_aal?: RequiredAuthenticatorAssuranceLevel; } /** * Configure how outgoing network calls behave. */ export interface GlobalOutgoingNetworkSettings { - http?: GlobalHTTPClientConfiguration - [k: string]: unknown | undefined + http?: GlobalHTTPClientConfiguration; + [k: string]: unknown | undefined; } /** * Configure how outgoing HTTP calls behave. */ export interface GlobalHTTPClientConfiguration { - disallow_private_ip_ranges?: DisallowPrivateIPRanges - private_ip_exception_urls?: AddExemptURLsToPrivateIPRanges - [k: string]: unknown | undefined + disallow_private_ip_ranges?: DisallowPrivateIPRanges; + private_ip_exception_urls?: AddExemptURLsToPrivateIPRanges; + [k: string]: unknown | undefined; } export interface FeatureFlags { - cacheable_sessions?: EnableOrySessionsCaching + cacheable_sessions?: EnableOrySessionsCaching; } diff --git a/test/e2e/cypress/support/index.d.ts b/test/e2e/cypress/support/index.d.ts index c9a52a0c7e8e..23257b231ac6 100644 --- a/test/e2e/cypress/support/index.d.ts +++ b/test/e2e/cypress/support/index.d.ts @@ -332,6 +332,11 @@ declare global { */ submitProfileForm(): Chainable + /** + * Submits a code form by clicking the button with method=code + */ + submitCodeForm(): Chainable + /** * Expect a CSRF error to occur * @@ -689,6 +694,17 @@ declare global { * Extracts a verification code from the received email */ getVerificationCodeFromEmail(email: string): Chainable + + /** + * Enables the registration code method + * @param enable + */ + enableRegistrationViaCode(enable: boolean): Chainable + + /** + * Extracts a registration code from the received email + */ + getRegistrationCodeFromEmail(email: string): Chainable } } } diff --git a/test/e2e/profiles/code/.kratos.yml b/test/e2e/profiles/code/.kratos.yml new file mode 100644 index 000000000000..de9de190d628 --- /dev/null +++ b/test/e2e/profiles/code/.kratos.yml @@ -0,0 +1,35 @@ +selfservice: + flows: + settings: + ui_url: http://localhost:4455/settings + privileged_session_max_age: 5m + + logout: + after: + default_browser_return_url: http://localhost:4455/login + + registration: + ui_url: http://localhost:4455/registration + after: + password: + hooks: + - hook: session + + login: + ui_url: http://localhost:4455/login + error: + ui_url: http://localhost:4455/error + verification: + ui_url: http://localhost:4455/verification + recovery: + ui_url: http://localhost:4455/recovery + methods: + code: + registration_enabled: true + login_enabled: true + enabled: false + +identity: + schemas: + - id: default + url: file://test/e2e/profiles/code/identity.traits.schema.json diff --git a/test/e2e/profiles/code/identity.traits.schema.json b/test/e2e/profiles/code/identity.traits.schema.json new file mode 100644 index 000000000000..994cf7dc31ec --- /dev/null +++ b/test/e2e/profiles/code/identity.traits.schema.json @@ -0,0 +1,35 @@ +{ + "$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Your E-Mail", + "minLength": 3, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "code": { + "identifier": true + } + }, + "verification": { + "via":"email" + } + } + } + }, + "required": [ + "email" + ] + } + } +} diff --git a/text/id.go b/text/id.go index bb7a85c5279e..a4f68fe647bf 100644 --- a/text/id.go +++ b/text/id.go @@ -23,6 +23,8 @@ const ( InfoSelfServiceLoginContinueWebAuthn // 1010011 InfoSelfServiceLoginWebAuthnPasswordless // 1010012 InfoSelfServiceLoginContinue // 1010013 + InfoSelfServiceLoginEmailWithCodeSent // 1010014 + InfoSelfServiceLoginCode // 1010015 ) const ( @@ -34,11 +36,13 @@ const ( ) const ( - InfoSelfServiceRegistrationRoot ID = 1040000 + iota // 1040000 - InfoSelfServiceRegistration // 1040001 - InfoSelfServiceRegistrationWith // 1040002 - InfoSelfServiceRegistrationContinue // 1040003 - InfoSelfServiceRegistrationRegisterWebAuthn // 1040004 + InfoSelfServiceRegistrationRoot ID = 1040000 + iota // 1040000 + InfoSelfServiceRegistration // 1040001 + InfoSelfServiceRegistrationWith // 1040002 + InfoSelfServiceRegistrationContinue // 1040003 + InfoSelfServiceRegistrationRegisterWebAuthn // 1040004 + InfoSelfServiceRegistrationEmailWithCodeSent // 1040005 + InfoSelfServiceRegistrationRegisterCode // 1040006 ) const ( @@ -83,6 +87,8 @@ const ( InfoNodeLabelContinue // 1070009 InfoNodeLabelRecoveryCode // 1070010 InfoNodeLabelVerificationCode // 1070011 + InfoNodeLabelRegistrationCode // 1070012 + InfoNodeLabelLoginCode // 1070013 ) const ( @@ -131,11 +137,13 @@ const ( ErrorValidationSettingsNoStrategyFound // 4010004 ErrorValidationRecoveryNoStrategyFound // 4010005 ErrorValidationVerificationNoStrategyFound // 4010006 + ErrorValidationLoginRetrySuccess // 4010007 ) const ( - ErrorValidationRegistration ID = 4040000 + iota - ErrorValidationRegistrationFlowExpired + ErrorValidationRegistration ID = 4040000 + iota + ErrorValidationRegistrationFlowExpired // 4040001 + ErrorValidateionRegistrationRetrySuccess // 4040002 ) const ( diff --git a/text/message_login.go b/text/message_login.go index 0a751530ddf0..a4336ecc4f9e 100644 --- a/text/message_login.go +++ b/text/message_login.go @@ -183,3 +183,29 @@ func NewInfoSelfServiceLoginContinue() *Message { Type: Info, } } + +func NewLoginEmailWithCodeSent() *Message { + return &Message{ + ID: InfoSelfServiceLoginEmailWithCodeSent, + Type: Info, + Text: "An email containing a code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and retry the login.", + Context: context(nil), + } +} + +func NewErrorValidationLoginRetrySuccessful() *Message { + return &Message{ + ID: ErrorValidationLoginRetrySuccess, + Type: Error, + Text: "The request was already completed successfully and can not be retried.", + Context: context(nil), + } +} + +func NewInfoSelfServiceLoginCode() *Message { + return &Message{ + ID: InfoSelfServiceLoginCode, + Type: Info, + Text: "Login with code", + } +} diff --git a/text/message_node.go b/text/message_node.go index f3712ea75b6d..d9f3a03a0009 100644 --- a/text/message_node.go +++ b/text/message_node.go @@ -27,6 +27,22 @@ func NewInfoNodeLabelRecoveryCode() *Message { } } +func NewInfoNodeLabelRegistrationCode() *Message { + return &Message{ + ID: InfoNodeLabelRegistrationCode, + Text: "Registration code", + Type: Info, + } +} + +func NewInfoNodeLabelLoginCode() *Message { + return &Message{ + ID: InfoNodeLabelLoginCode, + Text: "Login code", + Type: Info, + } +} + func NewInfoNodeInputPassword() *Message { return &Message{ ID: InfoNodeLabelInputPassword, diff --git a/text/message_registration.go b/text/message_registration.go index be9135cd06bb..32964a547498 100644 --- a/text/message_registration.go +++ b/text/message_registration.go @@ -54,3 +54,29 @@ func NewInfoSelfServiceRegistrationRegisterWebAuthn() *Message { Type: Info, } } + +func NewRegistrationEmailWithCodeSent() *Message { + return &Message{ + ID: InfoSelfServiceRegistrationEmailWithCodeSent, + Type: Info, + Text: "An email containing a code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and retry the registration.", + Context: context(nil), + } +} + +func NewErrorValidationRegistrationRetrySuccessful() *Message { + return &Message{ + ID: ErrorValidateionRegistrationRetrySuccess, + Type: Error, + Text: "The request was already completed successfully and can not be retried.", + Context: context(nil), + } +} + +func NewInfoSelfServiceRegistrationRegisterCode() *Message { + return &Message{ + ID: InfoSelfServiceRegistrationRegisterCode, + Text: "Sign up with code", + Type: Info, + } +} diff --git a/ui/container/container.go b/ui/container/container.go index af74fa0f00ce..b0e33714de47 100644 --- a/ui/container/container.go +++ b/ui/container/container.go @@ -190,7 +190,7 @@ func (c *Container) ParseError(group node.UiNodeGroup, err error) error { default: // The pointer can be ignored because if there is an error, we'll just use // the empty field (global error). - var causes = e.Causes + causes := e.Causes if len(e.Causes) == 0 { pointer, _ := jsonschemax.JSONPointerToDotNotation(e.InstancePtr) c.AddMessage(group, translateValidationError(e), pointer) @@ -310,6 +310,7 @@ func (c *Container) AddMessage(group node.UiNodeGroup, err *text.Message, setFor func (c *Container) Scan(value interface{}) error { return sqlxx.JSONScan(c, value) } + func (c *Container) Value() (driver.Value, error) { return sqlxx.JSONValue(c) } diff --git a/x/xsql/sql.go b/x/xsql/sql.go index f2354d082ca1..7ee2591bcd74 100644 --- a/x/xsql/sql.go +++ b/x/xsql/sql.go @@ -28,6 +28,8 @@ import ( func CleanSQL(t testing.TB, c *pop.Connection) { ctx := context.Background() for _, table := range []string{ + new(code.LoginCode).TableName(ctx), + new(code.RegistrationCode).TableName(ctx), new(continuity.Container).TableName(ctx), new(courier.MessageDispatch).TableName(), new(courier.Message).TableName(ctx),