diff --git a/docs/docs/self-service/flows/password-reset-account-recovery.md b/docs/docs/self-service/flows/password-reset-account-recovery.md index caa4338cc79c..05e7de435d02 100644 --- a/docs/docs/self-service/flows/password-reset-account-recovery.md +++ b/docs/docs/self-service/flows/password-reset-account-recovery.md @@ -14,3 +14,36 @@ recovered. Common use cases include: The forgot password flow is a work in progress and will be implemented in a future release of ORY Kratos. + +## Questions + +> One option is to allow the user to self-construct their own questions. The problem with this though is that you end up with either painfully obvious questions: +> +> - **What colour is the sky?** +> - **How do you spell “password”?** +> +> Questions which can put people in an uncomfortable position when a human uses the secret question for verification (such as in a call centre): +> +> **Who did I sleep with at the Christmas party?** +> +> When it comes to secret questions, people need to be saved from themselves! In other words, the site itself should +define the secret question, or rather define a series of secret questions from which the user can choose. +And not just choose one either; ideally, the user should define two or more secret questions at the time of account +registration which can then be used as a second channel of identity verification. Having multiple questions adds +a higher degree of confidence to the verification process plus gives you opportunity to add randomness (not always +show the same question) plus provides a bit of redundancy should someone legitimate forget an answer. +> +> So what makes a good secret question? There are a few different factors: +> +> * It should be concise – the question is to the point and unambiguous +> * The answer is specific – you don’t want a question which could be answered in different ways by the same person +> * The possible answers must be diverse – a question about someone’s favourite colour would result in a small subset of possible answers +> * Answer discovery should be hard – if you can readily find the answer for anyone (think high-profile people) then it’s no good +> * The answer must be constant over time – asking for someone’s favourite movie may result in a different answer a year from now +> +> [Source](https://www.troyhunt.com/everything-you-ever-wanted-to-know/) + +Here are some good examples: + +- What was the first concert you ever went to and where? (e.g. "Pink Floyd at Gotham City Stadium") +- ... diff --git a/driver/configuration/provider.go b/driver/configuration/provider.go index 781d8097e128..af3b047bf0c6 100644 --- a/driver/configuration/provider.go +++ b/driver/configuration/provider.go @@ -65,12 +65,14 @@ type Provider interface { VerificationURL() *url.URL ErrorURL() *url.URL MultiFactorURL() *url.URL + RecoveryURL() *url.URL SessionLifespan() time.Duration SelfServiceSettingsRequestLifespan() time.Duration SelfServiceVerificationRequestLifespan() time.Duration SelfServiceLoginRequestLifespan() time.Duration SelfServiceRegistrationRequestLifespan() time.Duration + SelfServiceRecoveryRequestLifespan() time.Duration SelfServiceStrategy(strategy string) *SelfServiceStrategy SelfServiceLoginBeforeHooks() []SelfServiceHook @@ -82,7 +84,7 @@ type Provider interface { SelfServiceSettingsAfterHooks(strategy string) []SelfServiceHook SelfServiceSettingsReturnTo(strategy string, defaultReturnTo *url.URL) *url.URL SelfServiceLogoutRedirectURL() *url.URL - SelfServiceVerificationLinkLifespan() time.Duration + SelfServicePrivilegedSessionMaxAge() time.Duration SelfServiceVerificationReturnTo() *url.URL diff --git a/driver/configuration/provider_viper.go b/driver/configuration/provider_viper.go index c632e48844a5..19b7fc3dee10 100644 --- a/driver/configuration/provider_viper.go +++ b/driver/configuration/provider_viper.go @@ -48,6 +48,7 @@ const ( ViperKeyURLsLogin = "urls.login_ui" ViperKeyURLsError = "urls.error_ui" ViperKeyURLsVerification = "urls.verify_ui" + ViperKeyURLsRecovery = "urls.recovery_ui" ViperKeyURLsSettings = "urls.settings_ui" ViperKeyURLsMFA = "urls.mfa_ui" ViperKeyURLsRegistration = "urls.registration_ui" @@ -73,9 +74,10 @@ const ( ViperKeySelfServiceSettingsRequestLifespan = "selfservice.settings.request_lifespan" ViperKeySelfServicePrivilegedAuthenticationAfter = "selfservice.settings.privileged_session_max_age" - ViperKeySelfServiceLifespanLink = "selfservice.verify.link_lifespan" - ViperKeySelfServiceLifespanVerificationRequest = "selfservice.verify.request_lifespan" - ViperKeySelfServiceVerifyReturnTo = "selfservice.verify.return_to" + ViperKeySelfServiceLifespanRecoveryRequest = "selfservice.recovery.request_lifespan" + + ViperKeySelfServiceLifespanVerificationRequest = "selfservice.verification.request_lifespan" + ViperKeySelfServiceVerifyReturnTo = "selfservice.verification.return_to" ViperKeyDefaultIdentityTraitsSchemaURL = "identity.traits.default_schema_url" ViperKeyIdentityTraitsSchemas = "identity.traits.schemas" @@ -299,6 +301,10 @@ func (p *ViperProvider) RegisterURL() *url.URL { return mustParseURLFromViper(p.l, ViperKeyURLsRegistration) } +func (p *ViperProvider) RecoveryURL() *url.URL { + return mustParseURLFromViper(p.l, ViperKeyURLsRecovery) +} + func (p *ViperProvider) SessionLifespan() time.Duration { return viperx.GetDuration(p.l, ViperKeyLifespanSession, time.Hour) } @@ -375,20 +381,18 @@ func (p *ViperProvider) VerificationURL() *url.URL { return mustParseURLFromViper(p.l, ViperKeyURLsVerification) } -// SelfServiceVerificationRequestLifespan defines the lifespan of a verification request (the ui interaction). This -// does not specify the lifespan of a verification code! func (p *ViperProvider) SelfServiceVerificationRequestLifespan() time.Duration { return viperx.GetDuration(p.l, ViperKeySelfServiceLifespanVerificationRequest, time.Hour) } -func (p *ViperProvider) SelfServiceVerificationLinkLifespan() time.Duration { - return viperx.GetDuration(p.l, ViperKeySelfServiceLifespanLink, time.Hour*24) -} - func (p *ViperProvider) SelfServiceVerificationReturnTo() *url.URL { return mustParseURLFromViper(p.l, ViperKeySelfServiceVerifyReturnTo) } +func (p *ViperProvider) SelfServiceRecoveryRequestLifespan() time.Duration { + return viperx.GetDuration(p.l, ViperKeySelfServiceLifespanRecoveryRequest, time.Hour) +} + func (p *ViperProvider) SelfServicePrivilegedSessionMaxAge() time.Duration { return viperx.GetDuration(p.l, ViperKeySelfServicePrivilegedAuthenticationAfter, time.Hour) } diff --git a/driver/registry.go b/driver/registry.go index ed66a7c36faa..2674bf7ef8ad 100644 --- a/driver/registry.go +++ b/driver/registry.go @@ -12,6 +12,7 @@ import ( "github.com/ory/kratos/continuity" "github.com/ory/kratos/courier" "github.com/ory/kratos/schema" + "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/selfservice/flow/verify" @@ -113,6 +114,12 @@ type Registry interface { verify.SenderProvider verify.HandlerProvider + recovery.RequestPersistenceProvider + recovery.ErrorHandlerProvider + recovery.StrategyProvider + recovery.HandlerProvider + recovery.StrategyProvider + x.CSRFTokenGeneratorProvider } diff --git a/driver/registry_default.go b/driver/registry_default.go index 6e1b2450f984..00265c9118d4 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -8,6 +8,7 @@ import ( "github.com/ory/kratos/continuity" "github.com/ory/kratos/schema" + "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/selfservice/flow/verify" "github.com/ory/kratos/selfservice/hook" @@ -106,6 +107,10 @@ type RegistryDefault struct { selfserviceVerifyHandler *verify.Handler selfserviceVerifySender *verify.Sender + selfserviceRecoveryErrorHandler *recovery.ErrorHandler + selfserviceRecoveryHandler *recovery.Handler + selfserviceRecoverySender *recovery.Sender + selfserviceLogoutHandler *logout.Handler selfserviceStrategies []interface{} @@ -113,6 +118,7 @@ type RegistryDefault struct { activeCredentialsCounterStrategies []identity.ActiveCredentialsCounter registrationStrategies []registration.Strategy profileStrategies []settings.Strategy + recoveryStrategies []recovery.Strategy buildVersion string buildHash string @@ -133,6 +139,7 @@ func (m *RegistryDefault) RegisterPublicRoutes(router *x.RouterPublic) { m.SelfServiceErrorHandler().RegisterPublicRoutes(router) m.SchemaHandler().RegisterPublicRoutes(router) m.VerificationHandler().RegisterPublicRoutes(router) + m.RecoveryHandler().RegisterPublicRoutes(router) m.HealthHandler().SetRoutes(router.Router, false) } @@ -145,6 +152,7 @@ func (m *RegistryDefault) RegisterAdminRoutes(router *x.RouterAdmin) { m.IdentityHandler().RegisterAdminRoutes(router) m.SessionHandler().RegisterAdminRoutes(router) m.SelfServiceErrorHandler().RegisterAdminRoutes(router) + m.RecoveryHandler().RegisterAdminRoutes(router) m.HealthHandler().SetRoutes(router.Router, true) } @@ -181,20 +189,6 @@ func (m *RegistryDefault) WithLogger(l logrus.FieldLogger) Registry { return m } -func (m *RegistryDefault) SettingsHandler() *settings.Handler { - if m.selfserviceSettingsHandler == nil { - m.selfserviceSettingsHandler = settings.NewHandler(m, m.c) - } - return m.selfserviceSettingsHandler -} - -func (m *RegistryDefault) SettingsRequestErrorHandler() *settings.ErrorHandler { - if m.selfserviceSettingsErrorHandler == nil { - m.selfserviceSettingsErrorHandler = settings.NewErrorHandler(m, m.c) - } - return m.selfserviceSettingsErrorHandler -} - func (m *RegistryDefault) LogoutHandler() *logout.Handler { if m.selfserviceLogoutHandler == nil { m.selfserviceLogoutHandler = logout.NewHandler(m, m.c) @@ -235,17 +229,6 @@ func (m *RegistryDefault) selfServiceStrategies() []interface{} { return m.selfserviceStrategies } -func (m *RegistryDefault) SettingsStrategies() settings.Strategies { - if len(m.profileStrategies) == 0 { - for _, strategy := range m.selfServiceStrategies() { - if s, ok := strategy.(settings.Strategy); ok { - m.profileStrategies = append(m.profileStrategies, s) - } - } - } - return m.profileStrategies -} - func (m *RegistryDefault) RegistrationStrategies() registration.Strategies { if len(m.registrationStrategies) == 0 { for _, strategy := range m.selfServiceStrategies() { @@ -472,6 +455,10 @@ func (m *RegistryDefault) RegistrationRequestPersister() registration.RequestPer return m.persister } +func (m *RegistryDefault) RecoveryRequestPersister() recovery.RequestPersister { + return m.persister +} + func (m *RegistryDefault) LoginRequestPersister() login.RequestPersister { return m.persister } diff --git a/driver/registry_default_recovery.go b/driver/registry_default_recovery.go new file mode 100644 index 000000000000..3f3b8b8d0ac0 --- /dev/null +++ b/driver/registry_default_recovery.go @@ -0,0 +1,40 @@ +package driver + +import ( + "github.com/ory/kratos/selfservice/flow/recovery" +) + +func (m *RegistryDefault) RecoveryRequestErrorHandler() *recovery.ErrorHandler { + if m.selfserviceRecoveryErrorHandler == nil { + m.selfserviceRecoveryErrorHandler = recovery.NewErrorHandler(m, m.c) + } + + return m.selfserviceRecoveryErrorHandler +} + +func (m *RegistryDefault) RecoveryHandler() *recovery.Handler { + if m.selfserviceRecoveryHandler == nil { + m.selfserviceRecoveryHandler = recovery.NewHandler(m, m.c) + } + + return m.selfserviceRecoveryHandler +} + +func (m *RegistryDefault) RecoverySender() *recovery.Sender { + if m.selfserviceRecoverySender == nil { + m.selfserviceRecoverySender = recovery.NewSender(m, m.c) + } + + return m.selfserviceRecoverySender +} + +func (m *RegistryDefault) RecoveryStrategies() recovery.Strategies { + if len(m.recoveryStrategies) == 0 { + for _, strategy := range m.selfServiceStrategies() { + if s, ok := strategy.(recovery.Strategy); ok { + m.recoveryStrategies = append(m.recoveryStrategies, s) + } + } + } + return m.recoveryStrategies +} diff --git a/driver/registry_default_profile.go b/driver/registry_default_settings.go similarity index 55% rename from driver/registry_default_profile.go rename to driver/registry_default_settings.go index e83f30d20691..ce25dbfa7e92 100644 --- a/driver/registry_default_profile.go +++ b/driver/registry_default_settings.go @@ -25,3 +25,28 @@ func (m *RegistryDefault) SettingsHookExecutor() *settings.HookExecutor { } return m.selfserviceSettingsExecutor } + +func (m *RegistryDefault) SettingsHandler() *settings.Handler { + if m.selfserviceSettingsHandler == nil { + m.selfserviceSettingsHandler = settings.NewHandler(m, m.c) + } + return m.selfserviceSettingsHandler +} + +func (m *RegistryDefault) SettingsRequestErrorHandler() *settings.ErrorHandler { + if m.selfserviceSettingsErrorHandler == nil { + m.selfserviceSettingsErrorHandler = settings.NewErrorHandler(m, m.c) + } + return m.selfserviceSettingsErrorHandler +} + +func (m *RegistryDefault) SettingsStrategies() settings.Strategies { + if len(m.profileStrategies) == 0 { + for _, strategy := range m.selfServiceStrategies() { + if s, ok := strategy.(settings.Strategy); ok { + m.profileStrategies = append(m.profileStrategies, s) + } + } + } + return m.profileStrategies +} diff --git a/go.mod b/go.mod index 8b02e9afaa5b..4e1689a67354 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/ory/kratos go 1.14 +replace github.com/ory/x => ../x + require ( github.com/Masterminds/sprig/v3 v3.0.0 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect @@ -64,12 +66,13 @@ require ( github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2 github.com/pkg/errors v0.9.1 github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e - github.com/sirupsen/logrus v1.5.0 + github.com/sirupsen/logrus v1.6.0 github.com/spf13/cobra v0.0.7 github.com/sqs/goreturns v0.0.0-20181028201513-538ac6014518 github.com/stretchr/testify v1.5.1 github.com/tidwall/gjson v1.3.5 github.com/tidwall/sjson v1.0.4 + github.com/uber/jaeger-lib v2.2.0+incompatible // indirect github.com/urfave/negroni v1.0.0 go.mongodb.org/mongo-driver v1.3.3 // indirect golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 @@ -79,6 +82,7 @@ require ( golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 // indirect golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d gopkg.in/go-playground/validator.v9 v9.28.0 + gopkg.in/gorp.v1 v1.7.2 // indirect gopkg.in/ini.v1 v1.56.0 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect ) diff --git a/go.sum b/go.sum index ebe1d99bfc9f..5027e172f0e0 100644 --- a/go.sum +++ b/go.sum @@ -743,6 +743,7 @@ github.com/konsorten/go-windows-terminal-sequences v0.0.0-20180402223658-b729f26 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -1031,6 +1032,8 @@ github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q= github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= diff --git a/identity/extension_verify.go b/identity/extension_verify.go index e1773de6c077..7cfe98790bd9 100644 --- a/identity/extension_verify.go +++ b/identity/extension_verify.go @@ -36,7 +36,7 @@ func (r *SchemaExtensionVerify) Run(ctx jsonschema.ValidationContext, s schema.E return err } - if has := r.has(r.i.Addresses, address); has != nil { + if has := r.has(r.i.VerifiableAddresses, address); has != nil { if r.has(r.v, address) == nil { r.v = append(r.v, *has) } @@ -65,6 +65,6 @@ func (r *SchemaExtensionVerify) has(haystack []VerifiableAddress, needle *Verifi } func (r *SchemaExtensionVerify) Finish() error { - r.i.Addresses = r.v + r.i.VerifiableAddresses = r.v return nil } diff --git a/identity/extension_verify_test.go b/identity/extension_verify_test.go index 1a23cf51d680..b4715c7c9e46 100644 --- a/identity/extension_verify_test.go +++ b/identity/extension_verify_test.go @@ -178,7 +178,7 @@ func TestSchemaExtensionVerify(t *testing.T) { }, } { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { - id := &Identity{ID: iid, Addresses: tc.existing} + id := &Identity{ID: iid, VerifiableAddresses: tc.existing} c := jsonschema.NewCompiler() runner, err := schema.NewExtensionRunner(schema.ExtensionRunnerIdentityMetaSchema) require.NoError(t, err) @@ -195,7 +195,7 @@ func TestSchemaExtensionVerify(t *testing.T) { require.NoError(t, e.Finish()) - addresses := id.Addresses + addresses := id.VerifiableAddresses require.Len(t, addresses, len(tc.expect)) for _, actual := range addresses { diff --git a/identity/identity.go b/identity/identity.go index 2fc05642347d..49f740ea68c0 100644 --- a/identity/identity.go +++ b/identity/identity.go @@ -54,7 +54,8 @@ type ( // required: true Traits Traits `json:"traits" faker:"-" db:"traits"` - Addresses []VerifiableAddress `json:"addresses,omitempty" faker:"-" has_many:"identity_verifiable_addresses" fk_id:"identity_id"` + VerifiableAddresses []VerifiableAddress `json:"verifiable_addresses,omitempty" faker:"-" has_many:"identity_verifiable_addresses" fk_id:"identity_id"` + RecoveryAddresses []RecoveryAddress `json:"recovery_addresses,omitempty" faker:"-" has_many:"identity_recovery_addresses" fk_id:"identity_id"` // CredentialsCollection is a helper struct field for gobuffalo.pop. CredentialsCollection CredentialsCollection `json:"-" faker:"-" has_many:"identity_credentials" fk_id:"identity_id"` @@ -153,11 +154,11 @@ func NewIdentity(traitsSchemaID string) *Identity { } return &Identity{ - ID: x.NewUUID(), - Credentials: map[CredentialsType]Credentials{}, - Traits: Traits(json.RawMessage("{}")), - TraitsSchemaID: traitsSchemaID, - l: new(sync.RWMutex), - Addresses: []VerifiableAddress{}, + ID: x.NewUUID(), + Credentials: map[CredentialsType]Credentials{}, + Traits: Traits("{}"), + TraitsSchemaID: traitsSchemaID, + VerifiableAddresses: []VerifiableAddress{}, + l: new(sync.RWMutex), } } diff --git a/identity/identity_recovery.go b/identity/identity_recovery.go new file mode 100644 index 000000000000..3883c0a41e3d --- /dev/null +++ b/identity/identity_recovery.go @@ -0,0 +1,76 @@ +package identity + +import ( + "time" + + "github.com/gofrs/uuid" + + "github.com/ory/kratos/otp" +) + +const ( + RecoveryAddressTypeEmail RecoveryAddressType = "email" +) + +type ( + // RecoveryAddressType must not exceed 16 characters as that is the limitation in the SQL Schema. + RecoveryAddressType string + + // RecoveryAddressStatus must not exceed 16 characters as that is the limitation in the SQL Schema. + RecoveryAddressStatus string + + // swagger:model recoveryIdentityAddress + RecoveryAddress struct { + // required: true + ID uuid.UUID `json:"id" db:"id" faker:"uuid" rw:"r"` + + // required: true + Value string `json:"value" db:"value"` + + // required: true + Via RecoveryAddressType `json:"via" db:"via"` + + // required: true + ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"` + + // IdentityID is a helper struct field for gobuffalo.pop. + IdentityID uuid.UUID `json:"-" faker:"-" db:"identity_id"` + // 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"` + // Code is the recovery code, never to be shared as JSON + Code string `json:"-" db:"code"` + } +) + +func (v RecoveryAddressType) HTMLFormInputType() string { + switch v { + case RecoveryAddressTypeEmail: + return "email" + } + return "" +} + +func (a RecoveryAddress) TableName() string { + return "identity_recovery_addresses" +} + +func NewRecoveryEmailAddress( + value string, + identity uuid.UUID, + expiresIn time.Duration, +) (*RecoveryAddress, error) { + code, err := otp.New() + if err != nil { + return nil, err + } + + return &RecoveryAddress{ + Code: code, + Value: value, + Via: RecoveryAddressTypeEmail, + ExpiresAt: time.Now().Add(expiresIn).UTC(), + IdentityID: identity, + }, nil +} diff --git a/identity/identity_recovery_test.go b/identity/identity_recovery_test.go new file mode 100644 index 000000000000..8ad83d0c1e57 --- /dev/null +++ b/identity/identity_recovery_test.go @@ -0,0 +1,27 @@ +package identity + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/x" +) + +func TestNewRecoveryEmailAddress(t *testing.T) { + iid := x.NewUUID() + a, err := NewRecoveryEmailAddress("foo@ory.sh", iid, time.Minute) + require.NoError(t, err) + + assert.Len(t, a.Code, 32) + assert.Equal(t, a.Value, "foo@ory.sh") + assert.Equal(t, a.Via, RecoveryAddressTypeEmail) + assert.NotEmpty(t, a.ID) + + out, err := json.Marshal(a) + require.NoError(t, err) + assert.NotContains(t, out, a.Code) +} diff --git a/identity/address.go b/identity/identity_verification.go similarity index 82% rename from identity/address.go rename to identity/identity_verification.go index ce5198f6c9fa..d411ef19c277 100644 --- a/identity/address.go +++ b/identity/identity_verification.go @@ -4,9 +4,8 @@ import ( "time" "github.com/gofrs/uuid" - "github.com/pkg/errors" - "github.com/ory/x/randx" + "github.com/ory/kratos/otp" ) const ( @@ -14,10 +13,6 @@ const ( VerifiableAddressStatusPending VerifiableAddressStatus = "pending" VerifiableAddressStatusCompleted VerifiableAddressStatus = "completed" - - // codeEntropy sets the number of characters used for generating verification codes. This must not be - // changed to another value as we only have 32 characters available in the SQL schema. - codeEntropy = 32 ) type ( @@ -70,20 +65,12 @@ func (a VerifiableAddress) TableName() string { return "identity_verifiable_addresses" } -func NewVerifyCode() (string, error) { - code, err := randx.RuneSequence(codeEntropy, randx.AlphaNum) - if err != nil { - return "", errors.WithStack(err) - } - return string(code), nil -} - func NewVerifiableEmailAddress( value string, identity uuid.UUID, expiresIn time.Duration, ) (*VerifiableAddress, error) { - code, err := NewVerifyCode() + code, err := otp.New() if err != nil { return nil, err } diff --git a/identity/address_test.go b/identity/identity_verification_test.go similarity index 100% rename from identity/address_test.go rename to identity/identity_verification_test.go diff --git a/identity/manager.go b/identity/manager.go index 7547d4539d92..96448766896f 100644 --- a/identity/manager.go +++ b/identity/manager.go @@ -16,6 +16,7 @@ import ( "github.com/ory/kratos/courier" "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/otp" ) var ErrProtectedFieldModified = herodot.ErrForbidden. @@ -80,9 +81,9 @@ func (m *Manager) requiresPrivilegedAccess(_ context.Context, original, updated return errors.WithStack(ErrProtectedFieldModified) } - if !reflect.DeepEqual(original.Addresses, updated.Addresses) && + if !reflect.DeepEqual(original.VerifiableAddresses, updated.VerifiableAddresses) && /* prevent nil != []string{} */ - len(original.Addresses)+len(updated.Addresses) != 0 { + len(original.VerifiableAddresses)+len(updated.VerifiableAddresses) != 0 { // reset the identity *updated = *original return errors.WithStack(ErrProtectedFieldModified) @@ -131,13 +132,13 @@ func (m *Manager) UpdateTraits(ctx context.Context, id uuid.UUID, traits Traits, } func (m *Manager) RefreshVerifyAddress(ctx context.Context, address *VerifiableAddress) error { - code, err := NewVerifyCode() + code, err := otp.New() if err != nil { return err } address.Code = code - address.ExpiresAt = time.Now().UTC().Add(m.c.SelfServiceVerificationLinkLifespan()) + address.ExpiresAt = time.Now().UTC().Add(m.c.SelfServiceVerificationRequestLifespan()) return m.r.IdentityPool().(PrivilegedPool).UpdateVerifiableAddress(ctx, address) } diff --git a/identity/manager_test.go b/identity/manager_test.go index 85c5d16e2ce4..285694d60325 100644 --- a/identity/manager_test.go +++ b/identity/manager_test.go @@ -30,9 +30,9 @@ func TestManager(t *testing.T) { checkExtensionFields := func(i *identity.Identity, expected string) func(*testing.T) { return func(t *testing.T) { - require.Len(t, i.Addresses, 1) - assert.EqualValues(t, expected, i.Addresses[0].Value) - assert.EqualValues(t, identity.VerifiableAddressTypeEmail, i.Addresses[0].Via) + require.Len(t, i.VerifiableAddresses, 1) + assert.EqualValues(t, expected, i.VerifiableAddresses[0].Value) + assert.EqualValues(t, identity.VerifiableAddressTypeEmail, i.VerifiableAddresses[0].Via) require.NotNil(t, i.Credentials[identity.CredentialsTypePassword]) assert.Equal(t, []string{expected}, i.Credentials[identity.CredentialsTypePassword].Identifiers) @@ -171,6 +171,6 @@ func TestManager(t *testing.T) { fromStore, err := reg.IdentityPool().GetIdentity(context.Background(), original.ID) require.NoError(t, err) - assert.NotEqual(t, pc, fromStore.Addresses[0].Code) + assert.NotEqual(t, pc, fromStore.VerifiableAddresses[0].Code) }) } diff --git a/identity/pool.go b/identity/pool.go index b70e2d49b715..a8197d6ad967 100644 --- a/identity/pool.go +++ b/identity/pool.go @@ -30,7 +30,7 @@ type ( Pool interface { ListIdentities(ctx context.Context, limit, offset int) ([]Identity, error) - // Get returns an identity by its id. Will return an error if the identity does not exist or backend + // GetIdentity returns an identity by its id. Will return an error if the identity does not exist or backend // connectivity is broken. GetIdentity(context.Context, uuid.UUID) (*Identity, error) @@ -383,10 +383,10 @@ func TestPool(p PrivilegedPool) func(t *testing.T) { require.NoError(t, err) address.ExpiresAt = address.ExpiresAt.Round(time.Minute) // prevent mysql time synchro issues - i.Addresses = append(i.Addresses, *address) + i.VerifiableAddresses = append(i.VerifiableAddresses, *address) require.NoError(t, p.CreateIdentity(context.Background(), &i)) - return i.Addresses[0] + return i.VerifiableAddresses[0] } t.Run("case=not found", func(t *testing.T) { diff --git a/identity/validator.go b/identity/validator.go index 5789cfe42252..8ada65e010b3 100644 --- a/identity/validator.go +++ b/identity/validator.go @@ -64,6 +64,6 @@ func (v *Validator) ValidateWithRunner(i *Identity, runners ...schema.Extension) func (v *Validator) Validate(i *Identity) error { return v.ValidateWithRunner(i, NewSchemaExtensionCredentials(i), - NewSchemaExtensionVerify(i, v.c.SelfServiceVerificationLinkLifespan()), + NewSchemaExtensionVerify(i, v.c.SelfServiceVerificationRequestLifespan()), ) } diff --git a/internal/.kratos.yaml b/internal/.kratos.yaml index cb748ed5e134..e26cd0cdfd31 100644 --- a/internal/.kratos.yaml +++ b/internal/.kratos.yaml @@ -128,3 +128,10 @@ selfservice: config: default_redirect_url: http://test.kratos.ory.sh:4000/ allow_user_defined_redirect: false + +# - job: account_activation +# config: +# redirect: +# pending: +# success: +# invalidate_after: 10h diff --git a/internal/httpclient/client/common/common_client.go b/internal/httpclient/client/common/common_client.go index f46427fab983..2026605226ef 100644 --- a/internal/httpclient/client/common/common_client.go +++ b/internal/httpclient/client/common/common_client.go @@ -31,6 +31,8 @@ type ClientService interface { GetSelfServiceBrowserLoginRequest(params *GetSelfServiceBrowserLoginRequestParams) (*GetSelfServiceBrowserLoginRequestOK, error) + GetSelfServiceBrowserRecoveryRequest(params *GetSelfServiceBrowserRecoveryRequestParams) (*GetSelfServiceBrowserRecoveryRequestOK, error) + GetSelfServiceBrowserRegistrationRequest(params *GetSelfServiceBrowserRegistrationRequestParams) (*GetSelfServiceBrowserRegistrationRequestOK, error) GetSelfServiceBrowserSettingsRequest(params *GetSelfServiceBrowserSettingsRequestParams) (*GetSelfServiceBrowserSettingsRequestOK, error) @@ -118,6 +120,46 @@ func (a *Client) GetSelfServiceBrowserLoginRequest(params *GetSelfServiceBrowser panic(msg) } +/* + GetSelfServiceBrowserRecoveryRequest gets the request context of browser based recovery flows + + When accessing this endpoint through ORY Kratos' Public API, ensure that cookies are set as they are required +for checking the auth session. To prevent scanning attacks, the public endpoint does not return 404 status codes +but instead 403 or 500. + +More information can be found at [ORY Kratos Account Recovery Documentation](../self-service/flows/password-reset-account-recovery). +*/ +func (a *Client) GetSelfServiceBrowserRecoveryRequest(params *GetSelfServiceBrowserRecoveryRequestParams) (*GetSelfServiceBrowserRecoveryRequestOK, error) { + // TODO: Validate the params before sending + if params == nil { + params = NewGetSelfServiceBrowserRecoveryRequestParams() + } + + result, err := a.transport.Submit(&runtime.ClientOperation{ + ID: "getSelfServiceBrowserRecoveryRequest", + Method: "GET", + PathPattern: "/self-service/browser/flows/requests/recovery", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json", "application/x-www-form-urlencoded"}, + Schemes: []string{"http", "https"}, + Params: params, + Reader: &GetSelfServiceBrowserRecoveryRequestReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + }) + if err != nil { + return nil, err + } + success, ok := result.(*GetSelfServiceBrowserRecoveryRequestOK) + if ok { + return success, nil + } + // unexpected success response + // safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue + msg := fmt.Sprintf("unexpected success response for getSelfServiceBrowserRecoveryRequest: API contract not enforced by server. Client expected to get an error, but got: %T", result) + panic(msg) +} + /* GetSelfServiceBrowserRegistrationRequest gets the request context of browser based registration user flows diff --git a/internal/httpclient/client/common/get_self_service_browser_recovery_request_parameters.go b/internal/httpclient/client/common/get_self_service_browser_recovery_request_parameters.go new file mode 100644 index 000000000000..0c376eb2828c --- /dev/null +++ b/internal/httpclient/client/common/get_self_service_browser_recovery_request_parameters.go @@ -0,0 +1,142 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package common + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewGetSelfServiceBrowserRecoveryRequestParams creates a new GetSelfServiceBrowserRecoveryRequestParams object +// with the default values initialized. +func NewGetSelfServiceBrowserRecoveryRequestParams() *GetSelfServiceBrowserRecoveryRequestParams { + var () + return &GetSelfServiceBrowserRecoveryRequestParams{ + + timeout: cr.DefaultTimeout, + } +} + +// NewGetSelfServiceBrowserRecoveryRequestParamsWithTimeout creates a new GetSelfServiceBrowserRecoveryRequestParams object +// with the default values initialized, and the ability to set a timeout on a request +func NewGetSelfServiceBrowserRecoveryRequestParamsWithTimeout(timeout time.Duration) *GetSelfServiceBrowserRecoveryRequestParams { + var () + return &GetSelfServiceBrowserRecoveryRequestParams{ + + timeout: timeout, + } +} + +// NewGetSelfServiceBrowserRecoveryRequestParamsWithContext creates a new GetSelfServiceBrowserRecoveryRequestParams object +// with the default values initialized, and the ability to set a context for a request +func NewGetSelfServiceBrowserRecoveryRequestParamsWithContext(ctx context.Context) *GetSelfServiceBrowserRecoveryRequestParams { + var () + return &GetSelfServiceBrowserRecoveryRequestParams{ + + Context: ctx, + } +} + +// NewGetSelfServiceBrowserRecoveryRequestParamsWithHTTPClient creates a new GetSelfServiceBrowserRecoveryRequestParams object +// with the default values initialized, and the ability to set a custom HTTPClient for a request +func NewGetSelfServiceBrowserRecoveryRequestParamsWithHTTPClient(client *http.Client) *GetSelfServiceBrowserRecoveryRequestParams { + var () + return &GetSelfServiceBrowserRecoveryRequestParams{ + HTTPClient: client, + } +} + +/*GetSelfServiceBrowserRecoveryRequestParams contains all the parameters to send to the API endpoint +for the get self service browser recovery request operation typically these are written to a http.Request +*/ +type GetSelfServiceBrowserRecoveryRequestParams struct { + + /*Request + Request is the Login Request ID + + The value for this parameter comes from `request` URL Query parameter sent to your + application (e.g. `/recover?request=abcde`). + + */ + Request string + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithTimeout adds the timeout to the get self service browser recovery request params +func (o *GetSelfServiceBrowserRecoveryRequestParams) WithTimeout(timeout time.Duration) *GetSelfServiceBrowserRecoveryRequestParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the get self service browser recovery request params +func (o *GetSelfServiceBrowserRecoveryRequestParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the get self service browser recovery request params +func (o *GetSelfServiceBrowserRecoveryRequestParams) WithContext(ctx context.Context) *GetSelfServiceBrowserRecoveryRequestParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the get self service browser recovery request params +func (o *GetSelfServiceBrowserRecoveryRequestParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the get self service browser recovery request params +func (o *GetSelfServiceBrowserRecoveryRequestParams) WithHTTPClient(client *http.Client) *GetSelfServiceBrowserRecoveryRequestParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the get self service browser recovery request params +func (o *GetSelfServiceBrowserRecoveryRequestParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithRequest adds the request to the get self service browser recovery request params +func (o *GetSelfServiceBrowserRecoveryRequestParams) WithRequest(request string) *GetSelfServiceBrowserRecoveryRequestParams { + o.SetRequest(request) + return o +} + +// SetRequest adds the request to the get self service browser recovery request params +func (o *GetSelfServiceBrowserRecoveryRequestParams) SetRequest(request string) { + o.Request = request +} + +// WriteToRequest writes these params to a swagger request +func (o *GetSelfServiceBrowserRecoveryRequestParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + // query param request + qrRequest := o.Request + qRequest := qrRequest + if qRequest != "" { + if err := r.SetQueryParam("request", qRequest); err != nil { + return err + } + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/httpclient/client/common/get_self_service_browser_recovery_request_responses.go b/internal/httpclient/client/common/get_self_service_browser_recovery_request_responses.go new file mode 100644 index 000000000000..1be5312ea14f --- /dev/null +++ b/internal/httpclient/client/common/get_self_service_browser_recovery_request_responses.go @@ -0,0 +1,225 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package common + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + "github.com/ory/kratos/internal/httpclient/models" +) + +// GetSelfServiceBrowserRecoveryRequestReader is a Reader for the GetSelfServiceBrowserRecoveryRequest structure. +type GetSelfServiceBrowserRecoveryRequestReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *GetSelfServiceBrowserRecoveryRequestReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 200: + result := NewGetSelfServiceBrowserRecoveryRequestOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + case 403: + result := NewGetSelfServiceBrowserRecoveryRequestForbidden() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 404: + result := NewGetSelfServiceBrowserRecoveryRequestNotFound() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 410: + result := NewGetSelfServiceBrowserRecoveryRequestGone() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 500: + result := NewGetSelfServiceBrowserRecoveryRequestInternalServerError() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + + default: + return nil, runtime.NewAPIError("unknown error", response, response.Code()) + } +} + +// NewGetSelfServiceBrowserRecoveryRequestOK creates a GetSelfServiceBrowserRecoveryRequestOK with default headers values +func NewGetSelfServiceBrowserRecoveryRequestOK() *GetSelfServiceBrowserRecoveryRequestOK { + return &GetSelfServiceBrowserRecoveryRequestOK{} +} + +/*GetSelfServiceBrowserRecoveryRequestOK handles this case with default header values. + +recoveryRequest +*/ +type GetSelfServiceBrowserRecoveryRequestOK struct { + Payload *models.RecoveryRequest +} + +func (o *GetSelfServiceBrowserRecoveryRequestOK) Error() string { + return fmt.Sprintf("[GET /self-service/browser/flows/requests/recovery][%d] getSelfServiceBrowserRecoveryRequestOK %+v", 200, o.Payload) +} + +func (o *GetSelfServiceBrowserRecoveryRequestOK) GetPayload() *models.RecoveryRequest { + return o.Payload +} + +func (o *GetSelfServiceBrowserRecoveryRequestOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.RecoveryRequest) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewGetSelfServiceBrowserRecoveryRequestForbidden creates a GetSelfServiceBrowserRecoveryRequestForbidden with default headers values +func NewGetSelfServiceBrowserRecoveryRequestForbidden() *GetSelfServiceBrowserRecoveryRequestForbidden { + return &GetSelfServiceBrowserRecoveryRequestForbidden{} +} + +/*GetSelfServiceBrowserRecoveryRequestForbidden handles this case with default header values. + +genericError +*/ +type GetSelfServiceBrowserRecoveryRequestForbidden struct { + Payload *models.GenericError +} + +func (o *GetSelfServiceBrowserRecoveryRequestForbidden) Error() string { + return fmt.Sprintf("[GET /self-service/browser/flows/requests/recovery][%d] getSelfServiceBrowserRecoveryRequestForbidden %+v", 403, o.Payload) +} + +func (o *GetSelfServiceBrowserRecoveryRequestForbidden) GetPayload() *models.GenericError { + return o.Payload +} + +func (o *GetSelfServiceBrowserRecoveryRequestForbidden) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.GenericError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewGetSelfServiceBrowserRecoveryRequestNotFound creates a GetSelfServiceBrowserRecoveryRequestNotFound with default headers values +func NewGetSelfServiceBrowserRecoveryRequestNotFound() *GetSelfServiceBrowserRecoveryRequestNotFound { + return &GetSelfServiceBrowserRecoveryRequestNotFound{} +} + +/*GetSelfServiceBrowserRecoveryRequestNotFound handles this case with default header values. + +genericError +*/ +type GetSelfServiceBrowserRecoveryRequestNotFound struct { + Payload *models.GenericError +} + +func (o *GetSelfServiceBrowserRecoveryRequestNotFound) Error() string { + return fmt.Sprintf("[GET /self-service/browser/flows/requests/recovery][%d] getSelfServiceBrowserRecoveryRequestNotFound %+v", 404, o.Payload) +} + +func (o *GetSelfServiceBrowserRecoveryRequestNotFound) GetPayload() *models.GenericError { + return o.Payload +} + +func (o *GetSelfServiceBrowserRecoveryRequestNotFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.GenericError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewGetSelfServiceBrowserRecoveryRequestGone creates a GetSelfServiceBrowserRecoveryRequestGone with default headers values +func NewGetSelfServiceBrowserRecoveryRequestGone() *GetSelfServiceBrowserRecoveryRequestGone { + return &GetSelfServiceBrowserRecoveryRequestGone{} +} + +/*GetSelfServiceBrowserRecoveryRequestGone handles this case with default header values. + +genericError +*/ +type GetSelfServiceBrowserRecoveryRequestGone struct { + Payload *models.GenericError +} + +func (o *GetSelfServiceBrowserRecoveryRequestGone) Error() string { + return fmt.Sprintf("[GET /self-service/browser/flows/requests/recovery][%d] getSelfServiceBrowserRecoveryRequestGone %+v", 410, o.Payload) +} + +func (o *GetSelfServiceBrowserRecoveryRequestGone) GetPayload() *models.GenericError { + return o.Payload +} + +func (o *GetSelfServiceBrowserRecoveryRequestGone) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.GenericError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewGetSelfServiceBrowserRecoveryRequestInternalServerError creates a GetSelfServiceBrowserRecoveryRequestInternalServerError with default headers values +func NewGetSelfServiceBrowserRecoveryRequestInternalServerError() *GetSelfServiceBrowserRecoveryRequestInternalServerError { + return &GetSelfServiceBrowserRecoveryRequestInternalServerError{} +} + +/*GetSelfServiceBrowserRecoveryRequestInternalServerError handles this case with default header values. + +genericError +*/ +type GetSelfServiceBrowserRecoveryRequestInternalServerError struct { + Payload *models.GenericError +} + +func (o *GetSelfServiceBrowserRecoveryRequestInternalServerError) Error() string { + return fmt.Sprintf("[GET /self-service/browser/flows/requests/recovery][%d] getSelfServiceBrowserRecoveryRequestInternalServerError %+v", 500, o.Payload) +} + +func (o *GetSelfServiceBrowserRecoveryRequestInternalServerError) GetPayload() *models.GenericError { + return o.Payload +} + +func (o *GetSelfServiceBrowserRecoveryRequestInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.GenericError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} diff --git a/internal/httpclient/client/common/get_self_service_browser_settings_request_parameters.go b/internal/httpclient/client/common/get_self_service_browser_settings_request_parameters.go index f78142315e2f..b5b4ebfc81e2 100644 --- a/internal/httpclient/client/common/get_self_service_browser_settings_request_parameters.go +++ b/internal/httpclient/client/common/get_self_service_browser_settings_request_parameters.go @@ -64,7 +64,7 @@ type GetSelfServiceBrowserSettingsRequestParams struct { Request is the Login Request ID The value for this parameter comes from `request` URL Query parameter sent to your - application (e.g. `/login?request=abcde`). + application (e.g. `/settingss?request=abcde`). */ Request string diff --git a/internal/httpclient/client/public/initialize_self_service_recovery_flow_parameters.go b/internal/httpclient/client/public/initialize_self_service_recovery_flow_parameters.go new file mode 100644 index 000000000000..0a5b3b892f51 --- /dev/null +++ b/internal/httpclient/client/public/initialize_self_service_recovery_flow_parameters.go @@ -0,0 +1,112 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package public + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewInitializeSelfServiceRecoveryFlowParams creates a new InitializeSelfServiceRecoveryFlowParams object +// with the default values initialized. +func NewInitializeSelfServiceRecoveryFlowParams() *InitializeSelfServiceRecoveryFlowParams { + + return &InitializeSelfServiceRecoveryFlowParams{ + + timeout: cr.DefaultTimeout, + } +} + +// NewInitializeSelfServiceRecoveryFlowParamsWithTimeout creates a new InitializeSelfServiceRecoveryFlowParams object +// with the default values initialized, and the ability to set a timeout on a request +func NewInitializeSelfServiceRecoveryFlowParamsWithTimeout(timeout time.Duration) *InitializeSelfServiceRecoveryFlowParams { + + return &InitializeSelfServiceRecoveryFlowParams{ + + timeout: timeout, + } +} + +// NewInitializeSelfServiceRecoveryFlowParamsWithContext creates a new InitializeSelfServiceRecoveryFlowParams object +// with the default values initialized, and the ability to set a context for a request +func NewInitializeSelfServiceRecoveryFlowParamsWithContext(ctx context.Context) *InitializeSelfServiceRecoveryFlowParams { + + return &InitializeSelfServiceRecoveryFlowParams{ + + Context: ctx, + } +} + +// NewInitializeSelfServiceRecoveryFlowParamsWithHTTPClient creates a new InitializeSelfServiceRecoveryFlowParams object +// with the default values initialized, and the ability to set a custom HTTPClient for a request +func NewInitializeSelfServiceRecoveryFlowParamsWithHTTPClient(client *http.Client) *InitializeSelfServiceRecoveryFlowParams { + + return &InitializeSelfServiceRecoveryFlowParams{ + HTTPClient: client, + } +} + +/*InitializeSelfServiceRecoveryFlowParams contains all the parameters to send to the API endpoint +for the initialize self service recovery flow operation typically these are written to a http.Request +*/ +type InitializeSelfServiceRecoveryFlowParams struct { + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithTimeout adds the timeout to the initialize self service recovery flow params +func (o *InitializeSelfServiceRecoveryFlowParams) WithTimeout(timeout time.Duration) *InitializeSelfServiceRecoveryFlowParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the initialize self service recovery flow params +func (o *InitializeSelfServiceRecoveryFlowParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the initialize self service recovery flow params +func (o *InitializeSelfServiceRecoveryFlowParams) WithContext(ctx context.Context) *InitializeSelfServiceRecoveryFlowParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the initialize self service recovery flow params +func (o *InitializeSelfServiceRecoveryFlowParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the initialize self service recovery flow params +func (o *InitializeSelfServiceRecoveryFlowParams) WithHTTPClient(client *http.Client) *InitializeSelfServiceRecoveryFlowParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the initialize self service recovery flow params +func (o *InitializeSelfServiceRecoveryFlowParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WriteToRequest writes these params to a swagger request +func (o *InitializeSelfServiceRecoveryFlowParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/httpclient/client/public/initialize_self_service_recovery_flow_responses.go b/internal/httpclient/client/public/initialize_self_service_recovery_flow_responses.go new file mode 100644 index 000000000000..69ff637a9068 --- /dev/null +++ b/internal/httpclient/client/public/initialize_self_service_recovery_flow_responses.go @@ -0,0 +1,97 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package public + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + "github.com/ory/kratos/internal/httpclient/models" +) + +// InitializeSelfServiceRecoveryFlowReader is a Reader for the InitializeSelfServiceRecoveryFlow structure. +type InitializeSelfServiceRecoveryFlowReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *InitializeSelfServiceRecoveryFlowReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 302: + result := NewInitializeSelfServiceRecoveryFlowFound() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 500: + result := NewInitializeSelfServiceRecoveryFlowInternalServerError() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + + default: + return nil, runtime.NewAPIError("unknown error", response, response.Code()) + } +} + +// NewInitializeSelfServiceRecoveryFlowFound creates a InitializeSelfServiceRecoveryFlowFound with default headers values +func NewInitializeSelfServiceRecoveryFlowFound() *InitializeSelfServiceRecoveryFlowFound { + return &InitializeSelfServiceRecoveryFlowFound{} +} + +/*InitializeSelfServiceRecoveryFlowFound handles this case with default header values. + +Empty responses are sent when, for example, resources are deleted. The HTTP status code for empty responses is +typically 201. +*/ +type InitializeSelfServiceRecoveryFlowFound struct { +} + +func (o *InitializeSelfServiceRecoveryFlowFound) Error() string { + return fmt.Sprintf("[GET /self-service/browser/flows/recovery][%d] initializeSelfServiceRecoveryFlowFound ", 302) +} + +func (o *InitializeSelfServiceRecoveryFlowFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + return nil +} + +// NewInitializeSelfServiceRecoveryFlowInternalServerError creates a InitializeSelfServiceRecoveryFlowInternalServerError with default headers values +func NewInitializeSelfServiceRecoveryFlowInternalServerError() *InitializeSelfServiceRecoveryFlowInternalServerError { + return &InitializeSelfServiceRecoveryFlowInternalServerError{} +} + +/*InitializeSelfServiceRecoveryFlowInternalServerError handles this case with default header values. + +genericError +*/ +type InitializeSelfServiceRecoveryFlowInternalServerError struct { + Payload *models.GenericError +} + +func (o *InitializeSelfServiceRecoveryFlowInternalServerError) Error() string { + return fmt.Sprintf("[GET /self-service/browser/flows/recovery][%d] initializeSelfServiceRecoveryFlowInternalServerError %+v", 500, o.Payload) +} + +func (o *InitializeSelfServiceRecoveryFlowInternalServerError) GetPayload() *models.GenericError { + return o.Payload +} + +func (o *InitializeSelfServiceRecoveryFlowInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.GenericError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} diff --git a/internal/httpclient/client/public/public_client.go b/internal/httpclient/client/public/public_client.go index cf0334492683..c14ba55bf25e 100644 --- a/internal/httpclient/client/public/public_client.go +++ b/internal/httpclient/client/public/public_client.go @@ -43,6 +43,8 @@ type ClientService interface { InitializeSelfServiceBrowserVerificationFlow(params *InitializeSelfServiceBrowserVerificationFlowParams) error + InitializeSelfServiceRecoveryFlow(params *InitializeSelfServiceRecoveryFlowParams) error + InitializeSelfServiceSettingsFlow(params *InitializeSelfServiceSettingsFlowParams) error SelfServiceBrowserVerify(params *SelfServiceBrowserVerifyParams) error @@ -338,6 +340,42 @@ func (a *Client) InitializeSelfServiceBrowserVerificationFlow(params *Initialize return nil } +/* + InitializeSelfServiceRecoveryFlow initializes browser based account recovery flow + + This endpoint initializes a browser-based account recovery flow. Once initialized, the browser will be redirected to +`urls.recovery_ui` with the request ID set as a query parameter. If a valid user session exists, the request +is aborted. + +> This endpoint is NOT INTENDED for API clients and only works +with browsers (Chrome, Firefox, ...). + +More information can be found at [ORY Kratos Account Recovery Documentation](../self-service/flows/password-reset-account-recovery). +*/ +func (a *Client) InitializeSelfServiceRecoveryFlow(params *InitializeSelfServiceRecoveryFlowParams) error { + // TODO: Validate the params before sending + if params == nil { + params = NewInitializeSelfServiceRecoveryFlowParams() + } + + _, err := a.transport.Submit(&runtime.ClientOperation{ + ID: "initializeSelfServiceRecoveryFlow", + Method: "GET", + PathPattern: "/self-service/browser/flows/recovery", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json", "application/x-www-form-urlencoded"}, + Schemes: []string{"http", "https"}, + Params: params, + Reader: &InitializeSelfServiceRecoveryFlowReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + }) + if err != nil { + return err + } + return nil +} + /* InitializeSelfServiceSettingsFlow initializes browser based settings flow diff --git a/internal/httpclient/models/identity.go b/internal/httpclient/models/identity.go index fbc0a84aee73..d0141d13dfa2 100644 --- a/internal/httpclient/models/identity.go +++ b/internal/httpclient/models/identity.go @@ -19,14 +19,14 @@ import ( // swagger:model Identity type Identity struct { - // addresses - Addresses []*VerifiableAddress `json:"addresses"` - // id // Required: true // Format: uuid4 ID UUID `json:"id"` + // recovery addresses + RecoveryAddresses []*RecoveryAddress `json:"recovery_addresses"` + // traits // Required: true Traits Traits `json:"traits"` @@ -39,17 +39,20 @@ type Identity struct { // // format: url TraitsSchemaURL string `json:"traits_schema_url,omitempty"` + + // verifiable addresses + VerifiableAddresses []*VerifiableAddress `json:"verifiable_addresses"` } // Validate validates this identity func (m *Identity) Validate(formats strfmt.Registry) error { var res []error - if err := m.validateAddresses(formats); err != nil { + if err := m.validateID(formats); err != nil { res = append(res, err) } - if err := m.validateID(formats); err != nil { + if err := m.validateRecoveryAddresses(formats); err != nil { res = append(res, err) } @@ -61,27 +64,43 @@ func (m *Identity) Validate(formats strfmt.Registry) error { res = append(res, err) } + if err := m.validateVerifiableAddresses(formats); err != nil { + res = append(res, err) + } + if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } -func (m *Identity) validateAddresses(formats strfmt.Registry) error { +func (m *Identity) validateID(formats strfmt.Registry) error { - if swag.IsZero(m.Addresses) { // not required + if err := m.ID.Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("id") + } + return err + } + + return nil +} + +func (m *Identity) validateRecoveryAddresses(formats strfmt.Registry) error { + + if swag.IsZero(m.RecoveryAddresses) { // not required return nil } - for i := 0; i < len(m.Addresses); i++ { - if swag.IsZero(m.Addresses[i]) { // not required + for i := 0; i < len(m.RecoveryAddresses); i++ { + if swag.IsZero(m.RecoveryAddresses[i]) { // not required continue } - if m.Addresses[i] != nil { - if err := m.Addresses[i].Validate(formats); err != nil { + if m.RecoveryAddresses[i] != nil { + if err := m.RecoveryAddresses[i].Validate(formats); err != nil { if ve, ok := err.(*errors.Validation); ok { - return ve.ValidateName("addresses" + "." + strconv.Itoa(i)) + return ve.ValidateName("recovery_addresses" + "." + strconv.Itoa(i)) } return err } @@ -92,31 +111,44 @@ func (m *Identity) validateAddresses(formats strfmt.Registry) error { return nil } -func (m *Identity) validateID(formats strfmt.Registry) error { +func (m *Identity) validateTraits(formats strfmt.Registry) error { - if err := m.ID.Validate(formats); err != nil { - if ve, ok := err.(*errors.Validation); ok { - return ve.ValidateName("id") - } + if err := validate.Required("traits", "body", m.Traits); err != nil { return err } return nil } -func (m *Identity) validateTraits(formats strfmt.Registry) error { +func (m *Identity) validateTraitsSchemaID(formats strfmt.Registry) error { - if err := validate.Required("traits", "body", m.Traits); err != nil { + if err := validate.Required("traits_schema_id", "body", m.TraitsSchemaID); err != nil { return err } return nil } -func (m *Identity) validateTraitsSchemaID(formats strfmt.Registry) error { +func (m *Identity) validateVerifiableAddresses(formats strfmt.Registry) error { + + if swag.IsZero(m.VerifiableAddresses) { // not required + return nil + } + + for i := 0; i < len(m.VerifiableAddresses); i++ { + if swag.IsZero(m.VerifiableAddresses[i]) { // not required + continue + } + + if m.VerifiableAddresses[i] != nil { + if err := m.VerifiableAddresses[i].Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("verifiable_addresses" + "." + strconv.Itoa(i)) + } + return err + } + } - if err := validate.Required("traits_schema_id", "body", m.TraitsSchemaID); err != nil { - return err } return nil diff --git a/internal/httpclient/models/recovery_address.go b/internal/httpclient/models/recovery_address.go new file mode 100644 index 000000000000..197186ff4e98 --- /dev/null +++ b/internal/httpclient/models/recovery_address.go @@ -0,0 +1,165 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// RecoveryAddress recovery address +// +// swagger:model RecoveryAddress +type RecoveryAddress struct { + + // expires at + // Required: true + // Format: date-time + ExpiresAt *strfmt.DateTime `json:"expires_at"` + + // id + // Required: true + // Format: uuid4 + ID UUID `json:"id"` + + // recovered + // Required: true + Recovered *bool `json:"recovered"` + + // recovered at + // Format: date-time + RecoveredAt strfmt.DateTime `json:"recovered_at,omitempty"` + + // value + // Required: true + Value *string `json:"value"` + + // via + // Required: true + Via RecoveryAddressType `json:"via"` +} + +// Validate validates this recovery address +func (m *RecoveryAddress) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateExpiresAt(formats); err != nil { + res = append(res, err) + } + + if err := m.validateID(formats); err != nil { + res = append(res, err) + } + + if err := m.validateRecovered(formats); err != nil { + res = append(res, err) + } + + if err := m.validateRecoveredAt(formats); err != nil { + res = append(res, err) + } + + if err := m.validateValue(formats); err != nil { + res = append(res, err) + } + + if err := m.validateVia(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *RecoveryAddress) validateExpiresAt(formats strfmt.Registry) error { + + if err := validate.Required("expires_at", "body", m.ExpiresAt); err != nil { + return err + } + + if err := validate.FormatOf("expires_at", "body", "date-time", m.ExpiresAt.String(), formats); err != nil { + return err + } + + return nil +} + +func (m *RecoveryAddress) validateID(formats strfmt.Registry) error { + + if err := m.ID.Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("id") + } + return err + } + + return nil +} + +func (m *RecoveryAddress) validateRecovered(formats strfmt.Registry) error { + + if err := validate.Required("recovered", "body", m.Recovered); err != nil { + return err + } + + return nil +} + +func (m *RecoveryAddress) validateRecoveredAt(formats strfmt.Registry) error { + + if swag.IsZero(m.RecoveredAt) { // not required + return nil + } + + if err := validate.FormatOf("recovered_at", "body", "date-time", m.RecoveredAt.String(), formats); err != nil { + return err + } + + return nil +} + +func (m *RecoveryAddress) validateValue(formats strfmt.Registry) error { + + if err := validate.Required("value", "body", m.Value); err != nil { + return err + } + + return nil +} + +func (m *RecoveryAddress) validateVia(formats strfmt.Registry) error { + + if err := m.Via.Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("via") + } + return err + } + + return nil +} + +// MarshalBinary interface implementation +func (m *RecoveryAddress) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *RecoveryAddress) UnmarshalBinary(b []byte) error { + var res RecoveryAddress + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/httpclient/models/recovery_address_type.go b/internal/httpclient/models/recovery_address_type.go new file mode 100644 index 000000000000..ad8f96679463 --- /dev/null +++ b/internal/httpclient/models/recovery_address_type.go @@ -0,0 +1,20 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "github.com/go-openapi/strfmt" +) + +// RecoveryAddressType recovery address type +// +// swagger:model RecoveryAddressType +type RecoveryAddressType string + +// Validate validates this recovery address type +func (m RecoveryAddressType) Validate(formats strfmt.Registry) error { + return nil +} diff --git a/internal/httpclient/models/recovery_request.go b/internal/httpclient/models/recovery_request.go new file mode 100644 index 000000000000..72914d101024 --- /dev/null +++ b/internal/httpclient/models/recovery_request.go @@ -0,0 +1,186 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// RecoveryRequest Request presents a recovery request +// +// This request is used when an identity wants to recover their account. +// +// We recommend reading the [Account Recovery Documentation](../self-service/flows/password-reset-account-recovery) +// +// swagger:model recoveryRequest +type RecoveryRequest struct { + + // Active, if set, contains the registration method that is being used. It is initially + // not set. + Active string `json:"active,omitempty"` + + // ExpiresAt is the time (UTC) when the request expires. If the user still wishes to update the setting, + // a new request has to be initiated. + // Required: true + // Format: date-time + ExpiresAt *strfmt.DateTime `json:"expires_at"` + + // id + // Required: true + // Format: uuid4 + ID UUID `json:"id"` + + // IssuedAt is the time (UTC) when the request occurred. + // Required: true + // Format: date-time + IssuedAt *strfmt.DateTime `json:"issued_at"` + + // Methods contains context for all account recovery methods. If a registration request has been + // processed, but for example the password is incorrect, this will contain error messages. + // Required: true + Methods map[string]RecoveryRequestMethod `json:"methods"` + + // RequestURL is the initial URL that was requested from ORY Kratos. It can be used + // to forward information contained in the URL's path or query for example. + // Required: true + RequestURL *string `json:"request_url"` + + // state + // Required: true + State State `json:"state"` +} + +// Validate validates this recovery request +func (m *RecoveryRequest) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateExpiresAt(formats); err != nil { + res = append(res, err) + } + + if err := m.validateID(formats); err != nil { + res = append(res, err) + } + + if err := m.validateIssuedAt(formats); err != nil { + res = append(res, err) + } + + if err := m.validateMethods(formats); err != nil { + res = append(res, err) + } + + if err := m.validateRequestURL(formats); err != nil { + res = append(res, err) + } + + if err := m.validateState(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *RecoveryRequest) validateExpiresAt(formats strfmt.Registry) error { + + if err := validate.Required("expires_at", "body", m.ExpiresAt); err != nil { + return err + } + + if err := validate.FormatOf("expires_at", "body", "date-time", m.ExpiresAt.String(), formats); err != nil { + return err + } + + return nil +} + +func (m *RecoveryRequest) validateID(formats strfmt.Registry) error { + + if err := m.ID.Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("id") + } + return err + } + + return nil +} + +func (m *RecoveryRequest) validateIssuedAt(formats strfmt.Registry) error { + + if err := validate.Required("issued_at", "body", m.IssuedAt); err != nil { + return err + } + + if err := validate.FormatOf("issued_at", "body", "date-time", m.IssuedAt.String(), formats); err != nil { + return err + } + + return nil +} + +func (m *RecoveryRequest) validateMethods(formats strfmt.Registry) error { + + for k := range m.Methods { + + if err := validate.Required("methods"+"."+k, "body", m.Methods[k]); err != nil { + return err + } + if val, ok := m.Methods[k]; ok { + if err := val.Validate(formats); err != nil { + return err + } + } + + } + + return nil +} + +func (m *RecoveryRequest) validateRequestURL(formats strfmt.Registry) error { + + if err := validate.Required("request_url", "body", m.RequestURL); err != nil { + return err + } + + return nil +} + +func (m *RecoveryRequest) validateState(formats strfmt.Registry) error { + + if err := m.State.Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("state") + } + return err + } + + return nil +} + +// MarshalBinary interface implementation +func (m *RecoveryRequest) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *RecoveryRequest) UnmarshalBinary(b []byte) error { + var res RecoveryRequest + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/httpclient/models/recovery_request_method.go b/internal/httpclient/models/recovery_request_method.go new file mode 100644 index 000000000000..0b47445026c0 --- /dev/null +++ b/internal/httpclient/models/recovery_request_method.go @@ -0,0 +1,74 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// RecoveryRequestMethod recovery request method +// +// swagger:model recoveryRequestMethod +type RecoveryRequestMethod struct { + + // config + Config *RequestMethodConfig `json:"config,omitempty"` + + // Method contains the request credentials type. + Method string `json:"method,omitempty"` +} + +// Validate validates this recovery request method +func (m *RecoveryRequestMethod) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateConfig(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *RecoveryRequestMethod) validateConfig(formats strfmt.Registry) error { + + if swag.IsZero(m.Config) { // not required + return nil + } + + if m.Config != nil { + if err := m.Config.Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("config") + } + return err + } + } + + return nil +} + +// MarshalBinary interface implementation +func (m *RecoveryRequestMethod) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *RecoveryRequestMethod) UnmarshalBinary(b []byte) error { + var res RecoveryRequestMethod + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/httpclient/models/settings_request.go b/internal/httpclient/models/settings_request.go index 965a6e4b0d8b..448e58ae7c82 100644 --- a/internal/httpclient/models/settings_request.go +++ b/internal/httpclient/models/settings_request.go @@ -56,7 +56,7 @@ type SettingsRequest struct { // Required: true RequestURL *string `json:"request_url"` - // UpdateSuccessful, if true, indicates that the settings request has been updated successfully with the provided data. + // Success, if true, indicates that the settings request has been updated successfully with the provided data. // Done will stay true when repeatedly checking. If set to true, done will revert back to false only // when a request with invalid (e.g. "please use a valid phone number") data was sent. // Required: true diff --git a/internal/httpclient/models/state.go b/internal/httpclient/models/state.go new file mode 100644 index 000000000000..95fdc78726da --- /dev/null +++ b/internal/httpclient/models/state.go @@ -0,0 +1,20 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "github.com/go-openapi/strfmt" +) + +// State state +// +// swagger:model State +type State string + +// Validate validates this state +func (m State) Validate(formats strfmt.Registry) error { + return nil +} diff --git a/internal/testhelpers/errorx.go b/internal/testhelpers/errorx.go index 454d3808fd55..f5777f31d83a 100644 --- a/internal/testhelpers/errorx.go +++ b/internal/testhelpers/errorx.go @@ -31,3 +31,16 @@ func NewErrorTestServer(t *testing.T, reg interface{ errorx.PersistenceProvider viper.Set(configuration.ViperKeyURLsError, ts.URL) return ts } + +func NewRedirTS(t *testing.T, body string) *httptest.Server { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(body) == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(ts.Close) + viper.Set(configuration.ViperKeyURLsDefaultReturnTo, ts.URL) + return ts +} diff --git a/internal/testhelpers/login.go b/internal/testhelpers/login.go new file mode 100644 index 000000000000..52939513d9a2 --- /dev/null +++ b/internal/testhelpers/login.go @@ -0,0 +1,26 @@ +package testhelpers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ory/viper" + + "github.com/ory/kratos/driver" + "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/x" +) + +func NewLoginUIRequestEchoServer(t *testing.T, reg driver.Registry) *httptest.Server { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + e, err := reg.LoginRequestPersister().GetLoginRequest(r.Context(), x.ParseUUID(r.URL.Query().Get("request"))) + require.NoError(t, err) + reg.Writer().Write(w, r, e) + })) + viper.Set(configuration.ViperKeyURLsLogin, ts.URL+"/login-ts") + t.Cleanup(ts.Close) + return ts +} diff --git a/internal/testhelpers/recovery.go b/internal/testhelpers/recovery.go new file mode 100644 index 000000000000..9120d94e1275 --- /dev/null +++ b/internal/testhelpers/recovery.go @@ -0,0 +1,47 @@ +package testhelpers + +import ( + "net/http" + "testing" + + "github.com/gobuffalo/httptest" + "github.com/julienschmidt/httprouter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/viper" + + "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/internal/httpclient/client/common" + "github.com/ory/kratos/selfservice/flow/settings" +) + +func NewRecoveryTestServer(t *testing.T) *httptest.Server { + router := httprouter.New() + router.GET("/recovery", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + w.WriteHeader(http.StatusNoContent) + }) + ts := httptest.NewServer(router) + t.Cleanup(ts.Close) + + viper.Set(configuration.ViperKeyURLsRecovery, ts.URL+"/recovery") + + return ts +} + +func GetRecoveryRequest(t *testing.T, primaryUser *http.Client, ts *httptest.Server) *common.GetSelfServiceBrowserRecoveryRequestOK { + publicClient := NewSDKClient(ts) + + res, err := primaryUser.Get(ts.URL + settings.PublicPath) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + + rs, err := publicClient.Common.GetSelfServiceBrowserRecoveryRequest( + common.NewGetSelfServiceBrowserRecoveryRequestParams().WithHTTPClient(primaryUser). + WithRequest(res.Request.URL.Query().Get("request")), + ) + require.NoError(t, err) + assert.Empty(t, rs.Payload.Active) + + return rs +} diff --git a/internal/testhelpers/server.go b/internal/testhelpers/server.go index e2a31b738c7f..c1844912992a 100644 --- a/internal/testhelpers/server.go +++ b/internal/testhelpers/server.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/gobuffalo/httptest" - "github.com/ory/viper" "github.com/ory/kratos/driver" @@ -13,12 +12,31 @@ import ( ) func NewKratosServer(t *testing.T, reg driver.Registry) (public, admin *httptest.Server) { - rp := x.NewRouterPublic() - ra := x.NewRouterAdmin() + return NewKratosServerWithRouters(t, reg, x.NewRouterPublic(), x.NewRouterAdmin()) + +} + +func NewKratosServerWithCSRF(t *testing.T, reg driver.Registry) (public, admin *httptest.Server) { + rp, ra := x.NewRouterPublic(), x.NewRouterAdmin() + public = httptest.NewServer(x.NewTestCSRFHandler(rp, reg)) + admin = httptest.NewServer(ra) + + viper.Set(configuration.ViperKeyURLsLogin, "http://NewKratosServerWithCSRF/i-am-a-mock-value") + viper.Set(configuration.ViperKeyURLsSelfPublic, public.URL) + viper.Set(configuration.ViperKeyURLsSelfAdmin, admin.URL) + + reg.RegisterRoutes(rp, ra) + + t.Cleanup(public.Close) + t.Cleanup(admin.Close) + return +} +func NewKratosServerWithRouters(t *testing.T, reg driver.Registry, rp *x.RouterPublic, ra *x.RouterAdmin) (public, admin *httptest.Server) { public = httptest.NewServer(rp) admin = httptest.NewServer(ra) + viper.Set(configuration.ViperKeyURLsLogin, "http://NewKratosServerWithRouters/i-am-a-mock-value") viper.Set(configuration.ViperKeyURLsSelfPublic, public.URL) viper.Set(configuration.ViperKeyURLsSelfAdmin, admin.URL) diff --git a/otp/otp.go b/otp/otp.go new file mode 100644 index 000000000000..f218356b1683 --- /dev/null +++ b/otp/otp.go @@ -0,0 +1,19 @@ +package otp + +import ( + "github.com/pkg/errors" + + "github.com/ory/x/randx" +) + +// Entropy sets the number of characters used for generating verification codes. This must not be +// changed to another value as we only have 32 characters available in the SQL schema. +const Entropy = 32 + +func New() (string, error) { + code, err := randx.RuneSequence(Entropy, randx.AlphaNum) + if err != nil { + return "", errors.WithStack(err) + } + return string(code), nil +} diff --git a/persistence/reference.go b/persistence/reference.go index 514b22524291..642d26c82a7d 100644 --- a/persistence/reference.go +++ b/persistence/reference.go @@ -11,6 +11,7 @@ import ( "github.com/ory/kratos/identity" "github.com/ory/kratos/selfservice/errorx" "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/verify" @@ -31,6 +32,7 @@ type Persister interface { session.Persister errorx.Persister verify.Persister + recovery.RequestPersister Close(context.Context) error Ping(context.Context) error diff --git a/persistence/sql/migrations/20191100000002_requests.up.fizz b/persistence/sql/migrations/20191100000002_requests.up.fizz index 5a4f1fe1677d..19e1f3a62ec9 100644 --- a/persistence/sql/migrations/20191100000002_requests.up.fizz +++ b/persistence/sql/migrations/20191100000002_requests.up.fizz @@ -1,47 +1,47 @@ create_table("selfservice_login_requests") { t.Column("id", "uuid", {primary: true}) - t.Column("request_url", "string", {"size": 2048}) - t.Column("issued_at", "timestamp", { default_raw: "CURRENT_TIMESTAMP" }) - t.Column("expires_at", "timestamp") - t.Column("active_method", "string", {"size": 32}) - t.Column("csrf_token", "string") + t.Column("request_url", "string", {"size": 2048}) + t.Column("issued_at", "timestamp", { default_raw: "CURRENT_TIMESTAMP" }) + t.Column("expires_at", "timestamp") + t.Column("active_method", "string", {"size": 32}) + t.Column("csrf_token", "string") } create_table("selfservice_login_request_methods") { t.Column("id", "uuid", {primary: true}) - t.Column("method", "string", {"size": 32}) - t.Column("selfservice_login_request_id", "uuid") - t.Column("config", "json") + t.Column("method", "string", {"size": 32}) + t.Column("selfservice_login_request_id", "uuid") + t.Column("config", "json") - t.ForeignKey("selfservice_login_request_id", {"selfservice_login_requests": ["id"]}, {"on_delete": "cascade"}) + t.ForeignKey("selfservice_login_request_id", {"selfservice_login_requests": ["id"]}, {"on_delete": "cascade"}) } create_table("selfservice_registration_requests") { t.Column("id", "uuid", {primary: true}) - t.Column("request_url", "string", {"size": 2048}) - t.Column("issued_at", "timestamp", { default_raw: "CURRENT_TIMESTAMP" }) - t.Column("expires_at", "timestamp") - t.Column("active_method", "string", {"size": 32}) - t.Column("csrf_token", "string") + t.Column("request_url", "string", {"size": 2048}) + t.Column("issued_at", "timestamp", { default_raw: "CURRENT_TIMESTAMP" }) + t.Column("expires_at", "timestamp") + t.Column("active_method", "string", {"size": 32}) + t.Column("csrf_token", "string") } create_table("selfservice_registration_request_methods") { - t.Column("id", "uuid", {primary: true}) - t.Column("method", "string", {"size": 32}) - t.Column("selfservice_registration_request_id", "uuid") - t.Column("config", "json") + t.Column("id", "uuid", {primary: true}) + t.Column("method", "string", {"size": 32}) + t.Column("selfservice_registration_request_id", "uuid") + t.Column("config", "json") - t.ForeignKey("selfservice_registration_request_id", {"selfservice_registration_requests": ["id"]}, {"on_delete": "cascade"}) + t.ForeignKey("selfservice_registration_request_id", {"selfservice_registration_requests": ["id"]}, {"on_delete": "cascade"}) } create_table("selfservice_profile_management_requests") { t.Column("id", "uuid", {primary: true}) - t.Column("request_url", "string", {"size": 2048}) - t.Column("issued_at", "timestamp", { default_raw: "CURRENT_TIMESTAMP" }) - t.Column("expires_at", "timestamp") - t.Column("form", "json") - t.Column("update_successful", "bool") - t.Column("identity_id", "uuid") + t.Column("request_url", "string", {"size": 2048}) + t.Column("issued_at", "timestamp", { default_raw: "CURRENT_TIMESTAMP" }) + t.Column("expires_at", "timestamp") + t.Column("form", "json") + t.Column("update_successful", "bool") + t.Column("identity_id", "uuid") - t.ForeignKey("identity_id", {"identities": ["id"]}, {"on_delete": "cascade"}) + t.ForeignKey("identity_id", {"identities": ["id"]}, {"on_delete": "cascade"}) } diff --git a/persistence/sql/migrations/20200519101057_create_recovery_addresses.down.fizz b/persistence/sql/migrations/20200519101057_create_recovery_addresses.down.fizz new file mode 100644 index 000000000000..be0249931463 --- /dev/null +++ b/persistence/sql/migrations/20200519101057_create_recovery_addresses.down.fizz @@ -0,0 +1 @@ +drop_table("recovery_addresses") diff --git a/persistence/sql/migrations/20200519101057_create_recovery_addresses.up.fizz b/persistence/sql/migrations/20200519101057_create_recovery_addresses.up.fizz new file mode 100644 index 000000000000..dbb10b9c625d --- /dev/null +++ b/persistence/sql/migrations/20200519101057_create_recovery_addresses.up.fizz @@ -0,0 +1,42 @@ +create_table("identity_recovery_addresses") { + t.Column("id", "uuid", {primary: true}) + + t.Column("code", "string", {"size": 32}) + t.Column("via", "string", {"size": 16}) + + t.Column("value", "string", {"size": 400}) + + t.Column("recovered_at", "timestamp", {"null": true}) + t.Column("expires_at", "timestamp", { default_raw: "CURRENT_TIMESTAMP" }) + + t.Column("identity_id", "uuid") + t.ForeignKey("identity_id", {"identities": ["id"]}, {"on_delete": "cascade"}) +} + +add_index("identity_recovery_addresses", ["code"], { "unique": true, "name": "identity_recovery_addresses_code_uq_idx" }) +add_index("identity_recovery_addresses", ["code"], { "name": "identity_recovery_addresses_code_idx" }) + +add_index("identity_recovery_addresses", ["via", "value"], { "unique": true, "name": "identity_recovery_addresses_status_via_uq_idx" }) +add_index("identity_recovery_addresses", ["via", "value"], { "name": "identity_recovery_addresses_status_via_idx" }) + +create_table("selfservice_recovery_requests") { + t.Column("id", "uuid", {primary: true}) + t.Column("request_url", "string", {"size": 2048}) + t.Column("issued_at", "timestamp", { default_raw: "CURRENT_TIMESTAMP" }) + t.Column("expires_at", "timestamp") + t.Column("active_method", "string", {"size": 32, "null": true}) + t.Column("csrf_token", "string") + t.Column("state", "string", {"size": 16}) + + t.Column("identity_recovery_address_id", "uuid") + t.ForeignKey("identity_recovery_address_id", {"identity_recovery_addresses": ["id"]}, {"on_delete": "cascade"}) +} + +create_table("selfservice_recovery_requests_methods") { + t.Column("id", "uuid", {primary: true}) + t.Column("method", "string", {"size": 32}) + t.Column("selfservice_recovery_request_id", "uuid") + t.Column("config", "json") + + t.ForeignKey("selfservice_recovery_request_id", {"selfservice_recovery_requests": ["id"]}, {"on_delete": "cascade"}) +} diff --git a/persistence/sql/migrations/20200519101058_create_recovery_addresses.mysql.down.sql b/persistence/sql/migrations/20200519101058_create_recovery_addresses.mysql.down.sql new file mode 100644 index 000000000000..a581fc46eb41 --- /dev/null +++ b/persistence/sql/migrations/20200519101058_create_recovery_addresses.mysql.down.sql @@ -0,0 +1 @@ +/* ALTER TABLE identity_recovery_addresses MODIFY COLUMN code VARCHAR(32); */ diff --git a/persistence/sql/migrations/20200519101058_create_recovery_addresses.mysql.up.sql b/persistence/sql/migrations/20200519101058_create_recovery_addresses.mysql.up.sql new file mode 100644 index 000000000000..0bc5a4b03ca2 --- /dev/null +++ b/persistence/sql/migrations/20200519101058_create_recovery_addresses.mysql.up.sql @@ -0,0 +1 @@ +ALTER TABLE identity_recovery_addresses MODIFY COLUMN code VARCHAR(32) BINARY; diff --git a/persistence/sql/persister_identity.go b/persistence/sql/persister_identity.go index 231a1bdf771a..f629cca78c1d 100644 --- a/persistence/sql/persister_identity.go +++ b/persistence/sql/persister_identity.go @@ -11,6 +11,7 @@ import ( "github.com/ory/jsonschema/v3" "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/otp" "github.com/gobuffalo/pop/v5" "github.com/gofrs/uuid" @@ -122,9 +123,19 @@ func createIdentityCredentials(ctx context.Context, tx *pop.Connection, i *ident } func createVerifiableAddresses(ctx context.Context, tx *pop.Connection, i *identity.Identity) error { - for k := range i.Addresses { - i.Addresses[k].IdentityID = i.ID - if err := tx.Create(&i.Addresses[k]); err != nil { + for k := range i.VerifiableAddresses { + i.VerifiableAddresses[k].IdentityID = i.ID + if err := tx.Create(&i.VerifiableAddresses[k]); err != nil { + return err + } + } + return nil +} + +func createRecoveryAddresses(ctx context.Context, tx *pop.Connection, i *identity.Identity) error { + for k := range i.RecoveryAddresses { + i.RecoveryAddresses[k].IdentityID = i.ID + if err := tx.Create(&i.RecoveryAddresses[k]); err != nil { return err } } @@ -159,6 +170,10 @@ func (p *Persister) CreateIdentity(ctx context.Context, i *identity.Identity) er return err } + if err := createRecoveryAddresses(ctx, tx, i); err != nil { + return err + } + return createIdentityCredentials(ctx, tx, i) })) } @@ -169,7 +184,7 @@ func (p *Persister) ListIdentities(ctx context.Context, limit, offset int) ([]id /* #nosec G201 TableName is static */ if err := sqlcon.HandleError(p.GetConnection(ctx). RawQuery(fmt.Sprintf("SELECT * FROM %s LIMIT ? OFFSET ?", new(identity.Identity).TableName()), limit, offset). - Eager("Addresses").All(&is)); err != nil { + Eager("VerifiableAddresses","RecoveryAddresses").All(&is)); err != nil { return nil, err } @@ -205,6 +220,12 @@ func (p *Persister) UpdateIdentity(ctx context.Context, i *identity.Identity) er return err } + // This is not required because it's cascading "ON DELETE": + // + // if err := tx.RawQuery(fmt.Sprintf(`DELETE FROM %s WHERE ...`, new(identity.RecoveryAddress).TableName()), i.ID).Exec(); err != nil { + // return err + // } + if err := tx.Update(i); err != nil { return err } @@ -213,6 +234,10 @@ func (p *Persister) UpdateIdentity(ctx context.Context, i *identity.Identity) er return err } + if err := createRecoveryAddresses(ctx, tx, i); err != nil { + return err + } + return createIdentityCredentials(ctx, tx, i) })) } @@ -231,7 +256,7 @@ func (p *Persister) DeleteIdentity(ctx context.Context, id uuid.UUID) error { func (p *Persister) GetIdentity(ctx context.Context, id uuid.UUID) (*identity.Identity, error) { var i identity.Identity - if err := p.GetConnection(ctx).Eager("Addresses").Find(&i, id); err != nil { + if err := p.GetConnection(ctx).Eager("VerifiableAddresses","RecoveryAddresses").Find(&i, id); err != nil { return nil, sqlcon.HandleError(err) } i.Credentials = nil @@ -299,7 +324,7 @@ func (p *Persister) FindAddressByValue(ctx context.Context, via identity.Verifia } func (p *Persister) VerifyAddress(ctx context.Context, code string) error { - newCode, err := identity.NewVerifyCode() + newCode, err := otp.New() if err != nil { return err } diff --git a/persistence/sql/persister_recovery.go b/persistence/sql/persister_recovery.go new file mode 100644 index 000000000000..875e0da8acf0 --- /dev/null +++ b/persistence/sql/persister_recovery.go @@ -0,0 +1,31 @@ +package sql + +import ( + "context" + + "github.com/gofrs/uuid" + + "github.com/ory/x/sqlcon" + + "github.com/ory/kratos/selfservice/flow/recovery" +) + +var _ recovery.RequestPersister = new(Persister) + +func (p Persister) CreateRecoveryRequest(ctx context.Context, r *recovery.Request) error { + // This should not create the request eagerly because otherwise we might accidentally create an address + // that isn't supposed to be in the database. + return p.GetConnection(ctx).Create(r) +} + +func (p Persister) GetRecoveryRequest(ctx context.Context, id uuid.UUID) (*recovery.Request, error) { + var r recovery.Request + if err := p.GetConnection(ctx).Find(&r, id); err != nil { + return nil, sqlcon.HandleError(err) + } + return &r, nil +} + +func (p Persister) UpdateRecoveryRequest(ctx context.Context, r *recovery.Request) error { + return sqlcon.HandleError(p.GetConnection(ctx).Update(r)) +} diff --git a/persistence/sql/persister_verify.go b/persistence/sql/persister_verify.go index 73576caa590b..e294954e9854 100644 --- a/persistence/sql/persister_verify.go +++ b/persistence/sql/persister_verify.go @@ -12,13 +12,13 @@ import ( var _ verify.Persister = new(Persister) -func (p Persister) CreateVerifyRequest(ctx context.Context, r *verify.Request) error { +func (p Persister) CreateVerificationRequest(ctx context.Context, r *verify.Request) error { // This should not create the request eagerly because otherwise we might accidentally create an address // that isn't supposed to be in the database. return p.GetConnection(ctx).Create(r) } -func (p Persister) GetVerifyRequest(ctx context.Context, id uuid.UUID) (*verify.Request, error) { +func (p Persister) GetVerificationRequest(ctx context.Context, id uuid.UUID) (*verify.Request, error) { var r verify.Request if err := p.GetConnection(ctx).Find(&r, id); err != nil { return nil, sqlcon.HandleError(err) @@ -26,6 +26,6 @@ func (p Persister) GetVerifyRequest(ctx context.Context, id uuid.UUID) (*verify. return &r, nil } -func (p Persister) UpdateVerifyRequest(ctx context.Context, r *verify.Request) error { +func (p Persister) UpdateVerificationRequest(ctx context.Context, r *verify.Request) error { return sqlcon.HandleError(p.GetConnection(ctx).Update(r)) } diff --git a/selfservice/flow/login/handler_test.go b/selfservice/flow/login/handler_test.go index 8cc1276c7eb8..c22b70e9be7e 100644 --- a/selfservice/flow/login/handler_test.go +++ b/selfservice/flow/login/handler_test.go @@ -30,20 +30,12 @@ func init() { func TestHandlerSettingForced(t *testing.T) { conf, reg := internal.NewFastRegistryWithMocks(t) - reg.WithCSRFTokenGenerator(x.FakeCSRFTokenGenerator) router := x.NewRouterPublic() - admin := x.NewRouterAdmin() - reg.LoginHandler().RegisterPublicRoutes(router) - reg.LoginHandler().RegisterAdminRoutes(admin) - reg.LoginStrategies().RegisterPublicRoutes(router) - ts := httptest.NewServer(router) - defer ts.Close() + ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, x.NewRouterAdmin()) - loginTS := httptest.NewServer(login.TestRequestHandler(t, reg)) + loginTS := testhelpers.NewLoginUIRequestEchoServer(t, reg) - viper.Set(configuration.ViperKeyURLsSelfPublic, ts.URL) - viper.Set(configuration.ViperKeyURLsLogin, loginTS.URL) viper.Set(configuration.ViperKeyURLsDefaultReturnTo, "https://www.ory.sh") viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/login.schema.json") @@ -123,22 +115,9 @@ func TestHandlerSettingForced(t *testing.T) { func TestLoginHandler(t *testing.T) { _, reg := internal.NewFastRegistryWithMocks(t) - - public, admin := func() (*httptest.Server, *httptest.Server) { - public := x.NewRouterPublic() - admin := x.NewRouterAdmin() - reg.LoginHandler().RegisterPublicRoutes(public) - reg.LoginHandler().RegisterAdminRoutes(admin) - reg.LoginStrategies().RegisterPublicRoutes(public) - return httptest.NewServer(x.NewTestCSRFHandler(public, reg)), httptest.NewServer(admin) - }() - defer public.Close() - defer admin.Close() - - redirTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - defer redirTS.Close() + public, admin := testhelpers.NewKratosServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + _ = testhelpers.NewRedirTS(t, "") newLoginTS := func(t *testing.T, upstream string, c *http.Client) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -174,12 +153,6 @@ func TestLoginHandler(t *testing.T) { } } - errTS := testhelpers.NewErrorTestServer(t, reg) - defer errTS.Close() - - viper.Set(configuration.ViperKeyURLsSelfPublic, public.URL) - viper.Set(configuration.ViperKeyURLsError, errTS.URL) - t.Run("daemon=admin", func(t *testing.T) { loginTS := newLoginTS(t, admin.URL, nil) defer loginTS.Close() diff --git a/selfservice/flow/login/request.go b/selfservice/flow/login/request.go index 34d8b43f75bd..00e99401da79 100644 --- a/selfservice/flow/login/request.go +++ b/selfservice/flow/login/request.go @@ -1,13 +1,9 @@ package login import ( - "context" "net/http" - "testing" "time" - "github.com/stretchr/testify/require" - "github.com/gobuffalo/pop/v5" "github.com/gofrs/uuid" "github.com/pkg/errors" @@ -137,16 +133,3 @@ func (r *Request) GetID() uuid.UUID { func (r *Request) IsForced() bool { return r.Forced } - -type testRequestHandlerDependencies interface { - RequestPersistenceProvider - x.WriterProvider -} - -func TestRequestHandler(t *testing.T, reg testRequestHandlerDependencies) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - e, err := reg.LoginRequestPersister().GetLoginRequest(context.Background(), x.ParseUUID(r.URL.Query().Get("request"))) - require.NoError(t, err) - reg.Writer().Write(w, r, e) - } -} diff --git a/selfservice/flow/recovery/error.go b/selfservice/flow/recovery/error.go new file mode 100644 index 000000000000..52ca123fe057 --- /dev/null +++ b/selfservice/flow/recovery/error.go @@ -0,0 +1,119 @@ +package recovery + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/pkg/errors" + + "github.com/ory/x/sqlxx" + + "github.com/ory/herodot" + "github.com/ory/x/urlx" + + "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/selfservice/errorx" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/x" +) + +var ( + ErrRequestExpired = herodot.ErrBadRequest. + WithError("recovery request expired"). + WithReasonf(`The recovery request has expired. Please restart the flow.`) + ErrHookAbortRequest = errors.New("aborted recovery hook execution") + ErrRequestNeedsReAuthentication = herodot.ErrForbidden.WithReasonf("The login session is too old and thus not allowed to update these fields. Please re-authenticate.") +) + +type ( + errorHandlerDependencies interface { + errorx.ManagementProvider + x.WriterProvider + x.LoggingProvider + + RequestPersistenceProvider + } + + ErrorHandlerProvider interface{ RecoveryRequestErrorHandler() *ErrorHandler } + + ErrorHandler struct { + d errorHandlerDependencies + c configuration.Provider + } +) + +func NewErrorHandler(d errorHandlerDependencies, c configuration.Provider) *ErrorHandler { + return &ErrorHandler{ + d: d, + c: c, + } +} + +func (s *ErrorHandler) reauthenticate( + w http.ResponseWriter, + r *http.Request, + rr *Request) { + if err := s.d.RecoveryRequestPersister().UpdateRecoveryRequest(r.Context(), rr); err != nil { + s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err) + return + } + + returnTo := urlx.CopyWithQuery(urlx.AppendPaths(s.c.SelfPublicURL(), r.URL.Path), r.URL.Query()) + s.c.SelfPublicURL() + u := urlx.AppendPaths( + urlx.CopyWithQuery(s.c.SelfPublicURL(), url.Values{ + "prompt": {"login"}, + "return_to": {returnTo.String()}, + }), login.BrowserLoginPath) + + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func (s *ErrorHandler) HandleRecoveryError( + w http.ResponseWriter, + r *http.Request, + rr *Request, + err error, + method string, +) { + s.d.Logger().WithError(err). + WithField("details", fmt.Sprintf("%+v", err)). + WithField("recovery_request", rr). + Warn("Encountered recovery error.") + + if rr == nil { + s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err) + return + } else if x.IsJSONRequest(r) { + s.d.Writer().WriteError(w, r, err) + return + } + + if errors.Is(err, ErrRequestNeedsReAuthentication) { + s.reauthenticate(w, r, rr) + return + } + + if _, ok := rr.Methods[method]; !ok { + s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Expected recovery method %s to exist.", method))) + return + } + + rr.Active = sqlxx.NullString(method) + + if err := rr.Methods[method].Config.ParseError(err); err != nil { + s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err) + return + } + + if err := s.d.RecoveryRequestPersister().UpdateRecoveryRequest(r.Context(), rr); err != nil { + s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err) + return + } + + http.Redirect(w, r, + urlx.CopyWithQuery(s.c.RecoveryURL(), url.Values{"request": {rr.ID.String()}}).String(), + http.StatusFound, + ) +} diff --git a/selfservice/flow/recovery/handler.go b/selfservice/flow/recovery/handler.go new file mode 100644 index 000000000000..332125a146b3 --- /dev/null +++ b/selfservice/flow/recovery/handler.go @@ -0,0 +1,175 @@ +package recovery + +import ( + "net/http" + "net/url" + "time" + + "github.com/julienschmidt/httprouter" + "github.com/justinas/nosurf" + "github.com/pkg/errors" + + "github.com/ory/x/urlx" + + "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/errorx" + "github.com/ory/kratos/session" + "github.com/ory/kratos/x" +) + +const ( + PublicRecoveryInitPath = "/self-service/browser/flows/recovery" + PublicRecoveryRequestPath = "/self-service/browser/flows/requests/recovery" + PublicRecoveryConfirmPath = "/self-service/browser/flows/recovery/:via/recover/:code" +) + +type ( + HandlerProvider interface { + RecoveryHandler() *Handler + } + handlerDependencies interface { + errorx.ManagementProvider + identity.ManagementProvider + identity.PrivilegedPoolProvider + session.HandlerProvider + StrategyProvider + RequestPersistenceProvider + SenderProvider + x.CSRFTokenGeneratorProvider + x.WriterProvider + } + Handler struct { + d handlerDependencies + c configuration.Provider + } +) + +func NewHandler(d handlerDependencies, c configuration.Provider) *Handler { + return &Handler{c: c, d: d} +} + +func (h *Handler) RegisterPublicRoutes(public *x.RouterPublic) { + redirect := session.RedirectOnAuthenticated(h.c) + public.GET(PublicRecoveryInitPath, h.d.SessionHandler().IsNotAuthenticated(h.init, redirect)) + public.GET(PublicRecoveryRequestPath, h.publicFetch) +} + +func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { + admin.GET(PublicRecoveryRequestPath, h.adminFetch) +} + +// swagger:route GET /self-service/browser/flows/recovery public initializeSelfServiceRecoveryFlow +// +// Initialize browser-based account recovery flow +// +// This endpoint initializes a browser-based account recovery flow. Once initialized, the browser will be redirected to +// `urls.recovery_ui` with the request ID set as a query parameter. If a valid user session exists, the request +// is aborted. +// +// > This endpoint is NOT INTENDED for API clients and only works +// with browsers (Chrome, Firefox, ...). +// +// More information can be found at [ORY Kratos Account Recovery Documentation](../self-service/flows/password-reset-account-recovery). +// +// Schemes: http, https +// +// Responses: +// 302: emptyResponse +// 500: genericError +func (h *Handler) init(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + a := NewRequest(h.c.SelfServiceRecoveryRequestLifespan(), h.d.GenerateCSRFToken(r), r) + for _, strategy := range h.d.RecoveryStrategies() { + if err := strategy.PopulateRecoveryMethod(r, a); err != nil { + h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err) + return + } + } + + if err := h.d.RecoveryRequestPersister().CreateRecoveryRequest(r.Context(), a); err != nil { + h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err) + return + } + + http.Redirect(w, r, + urlx.CopyWithQuery(h.c.RecoveryURL(), url.Values{"request": {a.ID.String()}}).String(), + http.StatusFound, + ) +} + +// nolint:deadcode,unused +// swagger:parameters getSelfServiceBrowserRecoveryRequest +type getSelfServiceBrowserRecoveryRequestParameters struct { + // Request is the Login Request ID + // + // The value for this parameter comes from `request` URL Query parameter sent to your + // application (e.g. `/recover?request=abcde`). + // + // required: true + // in: query + Request string `json:"request"` +} + +// swagger:route GET /self-service/browser/flows/requests/recovery common public admin getSelfServiceBrowserRecoveryRequest +// +// Get the request context of browser-based recovery flows +// +// When accessing this endpoint through ORY Kratos' Public API, ensure that cookies are set as they are required +// for checking the auth session. To prevent scanning attacks, the public endpoint does not return 404 status codes +// but instead 403 or 500. +// +// More information can be found at [ORY Kratos Account Recovery Documentation](../self-service/flows/password-reset-account-recovery). +// +// Produces: +// - application/json +// +// Schemes: http, https +// +// Responses: +// 200: recoveryRequest +// 403: genericError +// 404: genericError +// 410: genericError +// 500: genericError +func (h *Handler) publicFetch(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if err := h.fetchRequest(w, r, true); err != nil { + h.d.Writer().WriteError(w, r, err) + return + } +} + +func (h *Handler) adminFetch(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if err := h.fetchRequest(w, r, false); err != nil { + h.d.Writer().WriteError(w, r, err) + return + } +} + +func (h *Handler) wrapErrorForbidden(err error, shouldWrap bool) error { + if shouldWrap { + return x.ErrInvalidCSRFToken.WithTrace(err).WithDebugf("%s", err) + } + + return err +} + +func (h *Handler) fetchRequest(w http.ResponseWriter, r *http.Request, checkCSRF bool) error { + rid := x.ParseUUID(r.URL.Query().Get("request")) + req, err := h.d.RecoveryRequestPersister().GetRecoveryRequest(r.Context(), rid) + if err != nil { + return h.wrapErrorForbidden(err, checkCSRF) + } + + if checkCSRF && !nosurf.VerifyToken(h.d.GenerateCSRFToken(r), req.CSRFToken) { + return errors.WithStack(x.ErrInvalidCSRFToken) + } + + if req.ExpiresAt.Before(time.Now().UTC()) { + return errors.WithStack(x.ErrGone. + WithReason("The recovery request has expired. Redirect the user to the login endpoint to initialize a new session."). + WithDetail("redirect_to", urlx.AppendPaths(h.c.SelfPublicURL(), PublicRecoveryInitPath).String())) + } + + h.d.Writer().Write(w, r, req) + return nil +} diff --git a/selfservice/flow/recovery/handler_test.go b/selfservice/flow/recovery/handler_test.go new file mode 100644 index 000000000000..186ded8fcf7a --- /dev/null +++ b/selfservice/flow/recovery/handler_test.go @@ -0,0 +1,151 @@ +package recovery_test + +import ( + "context" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "testing" + "time" + + "github.com/justinas/nosurf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/ory/viper" + + "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow/recovery" + "github.com/ory/kratos/x" +) + +func init() { + internal.RegisterFakes() +} + +func TestHandlerRedirectOnAuthenticated(t *testing.T) { + conf, reg := internal.NewFastRegistryWithMocks(t) + + testhelpers.NewRecoveryTestServer(t) + redirTS := testhelpers.NewRedirTS(t, "already authenticated") + viper.Set(configuration.ViperKeyURLsLogin, redirTS.URL) + + router := x.NewRouterPublic() + testhelpers.NewErrorTestServer(t, reg) + public, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, x.NewRouterAdmin()) + + viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/identity.schema.json") + + t.Run("does redirect to default on authenticated request", func(t *testing.T) { + body, _ := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router.Router, x.NewTestHTTPRequest(t, "GET", public.URL+recovery.PublicRecoveryInitPath, nil)) + assert.EqualValues(t, "already authenticated", string(body)) + }) +} + +func TestRecoveryHandler(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + + testhelpers.NewRedirTS(t,"") + testhelpers.NewLoginUIRequestEchoServer(t,reg) + testhelpers.NewErrorTestServer(t, reg) + + public, admin := testhelpers.NewKratosServerWithCSRF(t,reg) + newRecoveryTS := func(t *testing.T, upstream string, c *http.Client) *httptest.Server { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c == nil { + c = http.DefaultClient + } + _, _ = w.Write(x.EasyGetBody(t, c, upstream+recovery.PublicRecoveryRequestPath+"?request="+r.URL.Query().Get("request"))) + })) + viper.Set(configuration.ViperKeyURLsRecovery, ts.URL) + t.Cleanup(ts.Close) + return ts + } + + assertRequestPayload := func(t *testing.T, body []byte) { + // assert.Equal(t, "password", gjson.GetBytes(body, "methods.password.method").String(), "%s", body) + // assert.NotEmpty(t, gjson.GetBytes(body, "methods.password.config.fields.#(name==csrf_token).value").String(), "%s", body) + assert.NotEmpty(t, gjson.GetBytes(body, "id").String(), "%s", body) + assert.Empty(t, gjson.GetBytes(body, "headers").Value(), "%s", body) + // assert.Contains(t, gjson.GetBytes(body, "methods.password.config.action").String(), gjson.GetBytes(body, "id").String(), "%s", body) + // assert.Contains(t, gjson.GetBytes(body, "methods.password.config.action").String(), public.URL, "%s", body) + } + + assertExpiredPayload := func(t *testing.T, res *http.Response, body []byte) { + assert.EqualValues(t, http.StatusGone, res.StatusCode) + assert.Equal(t, public.URL+recovery.PublicRecoveryInitPath, gjson.GetBytes(body, "error.details.redirect_to").String(), "%s", body) + } + + newExpiredRequest := func() *recovery.Request { + return &recovery.Request{ + ID: x.NewUUID(), + ExpiresAt: time.Now().Add(-time.Minute), + IssuedAt: time.Now().Add(-time.Minute * 2), + RequestURL: public.URL + recovery.PublicRecoveryInitPath, + CSRFToken: x.FakeCSRFToken, + } + } + + viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/recovery.schema.json") + + t.Run("daemon=admin", func(t *testing.T) { + regTS := newRecoveryTS(t, admin.URL, nil) + defer regTS.Close() + viper.Set(configuration.ViperKeyURLsRecovery, regTS.URL) + + t.Run("case=valid", func(t *testing.T) { + assertRequestPayload(t, x.EasyGetBody(t, public.Client(), public.URL+recovery.PublicRecoveryInitPath)) + }) + + t.Run("case=expired", func(t *testing.T) { + rr := newExpiredRequest() + require.NoError(t, reg.RecoveryRequestPersister().CreateRecoveryRequest(context.Background(), rr)) + res, body := x.EasyGet(t, admin.Client(), admin.URL+recovery.PublicRecoveryRequestPath+"?request="+rr.ID.String()) + assertExpiredPayload(t, res, body) + }) + }) + + t.Run("daemon=public", func(t *testing.T) { + t.Run("case=with_csrf", func(t *testing.T) { + j, err := cookiejar.New(nil) + require.NoError(t, err) + hc := &http.Client{Jar: j} + + newRecoveryTS(t, public.URL, hc) + + body := x.EasyGetBody(t, hc, public.URL+recovery.PublicRecoveryInitPath) + assertRequestPayload(t, body) + }) + + t.Run("case=without_csrf", func(t *testing.T) { + newRecoveryTS(t, public.URL, + // using a different client because it doesn't have access to the cookie jar + new(http.Client)) + + body := x.EasyGetBody(t, new(http.Client), public.URL+recovery.PublicRecoveryInitPath) + assert.Contains(t, gjson.GetBytes(body, "error").String(), "csrf_token", "%s", body) + }) + + t.Run("case=expired", func(t *testing.T) { + reg.WithCSRFTokenGenerator(x.FakeCSRFTokenGenerator) + t.Cleanup(func() { + reg.WithCSRFTokenGenerator(nosurf.Token) + }) + + j, err := cookiejar.New(nil) + require.NoError(t, err) + hc := &http.Client{Jar: j} + + regTS := newRecoveryTS(t, public.URL, hc) + defer regTS.Close() + + rr := newExpiredRequest() + require.NoError(t, reg.RecoveryRequestPersister().CreateRecoveryRequest(context.Background(), rr)) + res, body := x.EasyGet(t, admin.Client(), admin.URL+recovery.PublicRecoveryRequestPath+"?request="+rr.ID.String()) + assertExpiredPayload(t, res, body) + }) + }) +} diff --git a/selfservice/flow/recovery/persistence.go b/selfservice/flow/recovery/persistence.go new file mode 100644 index 000000000000..871b660d959e --- /dev/null +++ b/selfservice/flow/recovery/persistence.go @@ -0,0 +1,122 @@ +package recovery + +import ( + "context" + "encoding/json" + "testing" + + "github.com/bxcodec/faker" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/viper" + + "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/form" + "github.com/ory/kratos/x" +) + +type ( + RequestPersister interface { + CreateRecoveryRequest(context.Context, *Request) error + GetRecoveryRequest(ctx context.Context, id uuid.UUID) (*Request, error) + UpdateRecoveryRequest(context.Context, *Request) error + } + RequestPersistenceProvider interface { + RecoveryRequestPersister() RequestPersister + } +) + +func TestRequestPersister(p interface { + RequestPersister + identity.PrivilegedPool +}) func(t *testing.T) { + viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/identity.schema.json") + + var clearids = func(r *Request) { + r.ID = uuid.UUID{} + } + + return func(t *testing.T) { + t.Run("case=should error when the recovery request does not exist", func(t *testing.T) { + _, err := p.GetRecoveryRequest(context.Background(), x.NewUUID()) + require.Error(t, err) + }) + + var newRequest = func(t *testing.T) *Request { + var r Request + require.NoError(t, faker.FakeData(&r)) + clearids(&r) + return &r + } + + t.Run("case=should create a new recovery request", func(t *testing.T) { + r := newRequest(t) + err := p.CreateRecoveryRequest(context.Background(), r) + require.NoError(t, err, "%#v", err) + }) + + t.Run("case=should create with set ids", func(t *testing.T) { + var r Request + require.NoError(t, faker.FakeData(&r)) + require.NoError(t, p.CreateRecoveryRequest(context.Background(), &r)) + }) + + t.Run("case=should create and fetch a recovery request", func(t *testing.T) { + expected := newRequest(t) + err := p.CreateRecoveryRequest(context.Background(), expected) + require.NoError(t, err) + + actual, err := p.GetRecoveryRequest(context.Background(), expected.ID) + require.NoError(t, err) + + factual, _ := json.Marshal(actual.Methods[StrategyEmail].Config) + fexpected, _ := json.Marshal(expected.Methods[StrategyEmail].Config) + + require.NotEmpty(t, actual.Methods[StrategyEmail].Config.RequestMethodConfigurator.(*form.HTMLForm).Action) + assert.EqualValues(t, expected.ID, actual.ID) + assert.JSONEq(t, string(fexpected), string(factual)) + x.AssertEqualTime(t, expected.IssuedAt, actual.IssuedAt) + x.AssertEqualTime(t, expected.ExpiresAt, actual.ExpiresAt) + assert.EqualValues(t, expected.RequestURL, actual.RequestURL) + }) + + t.Run("case=should fail to create if identity does not exist", func(t *testing.T) { + var expected Request + require.NoError(t, faker.FakeData(&expected)) + clearids(&expected) + err := p.CreateRecoveryRequest(context.Background(), &expected) + require.Error(t, err) + }) + + t.Run("case=should create and update a recovery request", func(t *testing.T) { + expected := newRequest(t) + expected.Methods["oidc"] = &RequestMethod{ + Method: "oidc", Config: &RequestMethodConfig{RequestMethodConfigurator: &form.HTMLForm{Fields: []form.Field{{ + Name: "zab", Type: "bar", Pattern: "baz"}}}}} + expected.Methods["password"] = &RequestMethod{ + Method: "password", Config: &RequestMethodConfig{RequestMethodConfigurator: &form.HTMLForm{Fields: []form.Field{{ + Name: "foo", Type: "bar", Pattern: "baz"}}}}} + err := p.CreateRecoveryRequest(context.Background(), expected) + require.NoError(t, err) + + expected.Methods[StrategyEmail].Config.RequestMethodConfigurator.(*form.HTMLForm).Action = "/new-action" + expected.Methods["password"].Config.RequestMethodConfigurator.(*form.HTMLForm).Fields = []form.Field{{ + Name: "zab", Type: "zab", Pattern: "zab"}} + expected.RequestURL = "/new-request-url" + require.NoError(t, p.UpdateRecoveryRequest(context.Background(), expected)) + + actual, err := p.GetRecoveryRequest(context.Background(), expected.ID) + require.NoError(t, err) + + assert.Equal(t, "/new-action", actual.Methods[StrategyEmail].Config.RequestMethodConfigurator.(*form.HTMLForm).Action) + assert.Equal(t, "/new-request-url", actual.RequestURL) + assert.EqualValues(t, []form.Field{{Name: "zab", Type: "zab", Pattern: "zab"}}, actual. + Methods["password"].Config.RequestMethodConfigurator.(*form.HTMLForm).Fields) + assert.EqualValues(t, []form.Field{{Name: "zab", Type: "bar", Pattern: "baz"}}, actual. + Methods["oidc"].Config.RequestMethodConfigurator.(*form.HTMLForm).Fields) + }) + } +} diff --git a/selfservice/flow/recovery/request.go b/selfservice/flow/recovery/request.go new file mode 100644 index 000000000000..3ab1bc7cc154 --- /dev/null +++ b/selfservice/flow/recovery/request.go @@ -0,0 +1,162 @@ +package recovery + +import ( + "net/http" + "time" + + "github.com/gobuffalo/pop/v5" + "github.com/gofrs/uuid" + "github.com/pkg/errors" + + "github.com/ory/x/sqlxx" + + "github.com/ory/kratos/identity" + "github.com/ory/kratos/session" + "github.com/ory/kratos/x" +) + +type State string + +const ( + StateBlank = "" + StatePending = "pending" + StateSent = "sent" + StateConfirmed = "confirmed" + StateSuccess = "success" +) + +func NextState(current State) State { + switch current { + case StateBlank: + return StatePending + case StatePending: + return StateSent + case StateSent: + return StateConfirmed + case StateConfirmed: + return StateSuccess + } + return StateBlank +} + +// Request presents a recovery request +// +// This request is used when an identity wants to recover their account. +// +// We recommend reading the [Account Recovery Documentation](../self-service/flows/password-reset-account-recovery) +// +// swagger:model recoveryRequest +type Request struct { + // ID represents the request's unique ID. When performing the recovery flow, this + // represents the id in the recovery ui's query parameter: http://?request= + // + // required: true + // type: string + // format: uuid + ID uuid.UUID `json:"id" db:"id" faker:"uuid" rw:"r"` + + // ExpiresAt is the time (UTC) when the request expires. If the user still wishes to update the setting, + // a new request has to be initiated. + // + // required: true + ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"` + + // IssuedAt is the time (UTC) when the request occurred. + // + // required: true + IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"` + + // RequestURL is the initial URL that was requested from ORY Kratos. It can be used + // to forward information contained in the URL's path or query for example. + // + // required: true + RequestURL string `json:"request_url" db:"request_url"` + + // Active, if set, contains the registration method that is being used. It is initially + // not set. + Active sqlxx.NullString `json:"active,omitempty" db:"active_method"` + + // Methods contains context for all account recovery methods. If a registration request has been + // processed, but for example the password is incorrect, this will contain error messages. + // + // required: true + Methods map[string]*RequestMethod `json:"methods" faker:"recovery_request_methods" db:"-"` + + // MethodsRaw is a helper struct field for gobuffalo.pop. + MethodsRaw RequestMethodsRaw `json:"-" faker:"-" has_many:"selfservice_recovery_request_methods" fk_id:"selfservice_recovery_request_id"` + + // RecoveryAddress links this request to a recovery address. + RecoveryAddress *identity.RecoveryAddress `json:"-" belongs_to:"identity_recovery_addresses" fk_id:"RecoveryAddressID"` + + // State represents the state of this request. Can be one of: + // + // - pending + // - sent + // - confirmed + // - success + // + // required: true + State State `json:"state" faker:"-" db:"state"` + + // CSRFToken contains the anti-csrf token associated with this request. + CSRFToken string `json:"-" db:"csrf_token"` + + // 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"` + // RecoveryAddressID is a helper struct field for gobuffalo.pop. + RecoveryAddressID uuid.UUID `json:"-" faker:"-" db:"identity_recovery_address_id"` +} + +func NewRequest(exp time.Duration,csrf string, r *http.Request) *Request { + return &Request{ + ID: x.NewUUID(), + ExpiresAt: time.Now().UTC().Add(exp), + IssuedAt: time.Now().UTC(), + RequestURL: x.RequestURL(r).String(), + Methods: map[string]*RequestMethod{}, + State: NextState(StateBlank), + CSRFToken: csrf, + } +} + +func (r *Request) TableName() string { + return "selfservice_recovery_requests" +} + +func (r *Request) GetID() uuid.UUID { + return r.ID +} + +func (r *Request) Valid(s *session.Session) error { + if r.ExpiresAt.Before(time.Now().UTC()) { + return errors.WithStack(ErrRequestExpired. + WithReasonf("The recovery request expired %.2f minutes ago, please try again.", + -time.Since(r.ExpiresAt).Minutes())) + } + return nil +} + +func (r *Request) BeforeSave(_ *pop.Connection) error { + r.MethodsRaw = make([]RequestMethod, 0, len(r.Methods)) + for _, m := range r.Methods { + r.MethodsRaw = append(r.MethodsRaw, *m) + } + r.Methods = nil + return nil +} + +func (r *Request) AfterSave(c *pop.Connection) error { + return r.AfterFind(c) +} + +func (r *Request) AfterFind(_ *pop.Connection) error { + r.Methods = make(RequestMethods) + for key := range r.MethodsRaw { + m := r.MethodsRaw[key] // required for pointer dereference + r.Methods[m.Method] = &m + } + r.MethodsRaw = nil + return nil +} diff --git a/selfservice/flow/recovery/request_method.go b/selfservice/flow/recovery/request_method.go new file mode 100644 index 000000000000..899817d46548 --- /dev/null +++ b/selfservice/flow/recovery/request_method.go @@ -0,0 +1,98 @@ +package recovery + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/ory/x/sqlxx" + + "github.com/gofrs/uuid" + + "github.com/ory/kratos/selfservice/form" +) + +// swagger:model recoveryRequestMethod +type RequestMethod struct { + // Method contains the request credentials type. + Method string `json:"method" db:"method"` + + // Config is the credential type's config. + Config *RequestMethodConfig `json:"config" db:"config"` + + // ID is a helper struct field for gobuffalo.pop. + ID uuid.UUID `json:"-" db:"id" rw:"r"` + + // RequestID is a helper struct field for gobuffalo.pop. + RequestID uuid.UUID `json:"-" db:"selfservice_recovery_request_id"` + + // Request is a helper struct field for gobuffalo.pop. + Request *Request `json:"-" belongs_to:"selfservice_recovery_request" fk_id:"RequestID"` + + // CreatedAt is a helper struct field for gobuffalo.pop. + CreatedAt time.Time `json:"-" db:"created_at"` + + // UpdatedAt is a helper struct field for gobuffalo.pop. + UpdatedAt time.Time `json:"-" db:"updated_at"` +} + +func (u RequestMethod) TableName() string { + return "selfservice_recovery_request_methods" +} + +type RequestMethodsRaw []RequestMethod // workaround for https://github.com/gobuffalo/pop/pull/478 +type RequestMethods map[string]*RequestMethod + +func (u RequestMethods) TableName() string { + // This must be stay a value receiver, using a pointer receiver will cause issues with pop. + return "selfservice_recovery_request_methods" +} + +func (u RequestMethodsRaw) TableName() string { + // This must be stay a value receiver, using a pointer receiver will cause issues with pop. + return "selfservice_recovery_request_methods" +} + +// swagger:ignore +type RequestMethodConfigurator interface { + form.ErrorParser + form.FieldSetter + form.FieldUnsetter + form.ValueSetter + form.Resetter + form.ErrorResetter + form.CSRFSetter + form.FieldSorter + form.ErrorAdder +} + +// swagger:type recoveryRequestConfigPayload +type RequestMethodConfig struct { + // swagger:ignore + RequestMethodConfigurator + + swaggerRequestMethodConfig +} + +// swagger:model recoveryRequestConfigPayload +type swaggerRequestMethodConfig struct { + *form.HTMLForm +} + +func (c *RequestMethodConfig) Scan(value interface{}) error { + return sqlxx.JSONScan(c, value) +} + +func (c *RequestMethodConfig) Value() (driver.Value, error) { + return sqlxx.JSONValue(c) +} + +func (c *RequestMethodConfig) UnmarshalJSON(data []byte) error { + c.RequestMethodConfigurator = form.NewHTMLForm("") + return json.Unmarshal(data, c.RequestMethodConfigurator) +} + +func (c *RequestMethodConfig) MarshalJSON() ([]byte, error) { + out, err := json.Marshal(c.RequestMethodConfigurator) + return out, err +} diff --git a/selfservice/flow/recovery/request_test.go b/selfservice/flow/recovery/request_test.go new file mode 100644 index 000000000000..c5c6527b81a5 --- /dev/null +++ b/selfservice/flow/recovery/request_test.go @@ -0,0 +1,42 @@ +package recovery_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ory/x/urlx" + + "github.com/ory/kratos/selfservice/flow/recovery" + "github.com/ory/kratos/session" +) + +func TestRequest(t *testing.T) { + u := &http.Request{URL: urlx.ParseOrPanic("http://foo/bar/baz"), Host: "foo"} + for k, tc := range []struct { + r *recovery.Request + s *session.Session + expectErr bool + }{ + { + r: recovery.NewRequest(time.Hour, "", u), + }, + { + r: recovery.NewRequest(-time.Hour, "", u), + expectErr: true, + }, + } { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + err := tc.r.Valid(tc.s) + if tc.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} diff --git a/selfservice/flow/recovery/sender.go b/selfservice/flow/recovery/sender.go new file mode 100644 index 000000000000..40a1eabcf61c --- /dev/null +++ b/selfservice/flow/recovery/sender.go @@ -0,0 +1,100 @@ +package recovery + +import ( + "context" + "strings" + + "github.com/pkg/errors" + + "github.com/ory/go-convenience/urlx" + "github.com/ory/x/errorsx" + "github.com/ory/x/sqlcon" + + "github.com/ory/kratos/courier" + templates "github.com/ory/kratos/courier/template" + "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/x" +) + +var ErrUnknownAddress = errors.New("recovery requested for unknown address") + +type ( + senderDependencies interface { + courier.Provider + identity.PoolProvider + identity.ManagementProvider + x.LoggingProvider + } + SenderProvider interface { + RecoverySender() *Sender + } + Sender struct { + r senderDependencies + c configuration.Provider + } +) + +func NewSender(r senderDependencies, c configuration.Provider) *Sender { + return &Sender{r: r, c: c} +} + +func (m *Sender) IssueAndSendRecoveryToken(ctx context.Context, address, via string) (*identity.VerifiableAddress, error) { + m.r.Logger().WithField("via", via).Debug("Sending out verification code.") + + a, err := m.r.IdentityPool().FindAddressByValue(ctx, identity.VerifiableAddressTypeEmail, address) + if err != nil { + if errorsx.Cause(err) == sqlcon.ErrNoRows { + if err := m.sendToUnknownAddress(ctx, identity.VerifiableAddressTypeEmail, address); err != nil { + return nil, err + } + return nil, errors.Cause(ErrUnknownAddress) + } + return nil, err + } + + if err := m.r.IdentityManager().RefreshVerifyAddress(ctx, a); err != nil { + return nil, err + } + + if err := m.sendCodeToKnownAddress(ctx, a); err != nil { + return nil, err + } + return a, nil +} + +func (m *Sender) sendToUnknownAddress(ctx context.Context, via identity.VerifiableAddressType, address string) error { + m.r.Logger().WithField("via", via).Debug("Sending out unsuccessful recovery message because address is unknown.") + return m.run(via, func() error { + _, err := m.r.Courier().QueueEmail(ctx, + templates.NewVerifyInvalid(m.c, &templates.VerifyInvalidModel{To: address})) + return err + }) +} + +func (m *Sender) sendCodeToKnownAddress(ctx context.Context, address *identity.VerifiableAddress) error { + m.r.Logger().WithField("via", address.Via).Debug("Sending out recovery message.") + return m.run(address.Via, func() error { + _, err := m.r.Courier().QueueEmail(ctx, templates.NewVerifyValid(m.c, + &templates.VerifyValidModel{ + To: address.Value, + VerifyURL: urlx.AppendPaths( + m.c.SelfPublicURL(), + strings.ReplaceAll( + strings.ReplaceAll(PublicRecoveryConfirmPath, ":via", string(address.Via)), + ":code", address.Code)). + String(), + }, + )) + return err + }) +} + +func (m *Sender) run(via identity.VerifiableAddressType, emailFunc func() error) error { + switch via { + case identity.VerifiableAddressTypeEmail: + return emailFunc() + default: + return errors.Errorf("received unexpected via type: %s", via) + } +} diff --git a/selfservice/flow/recovery/sender_test.go b/selfservice/flow/recovery/sender_test.go new file mode 100644 index 000000000000..009129e95581 --- /dev/null +++ b/selfservice/flow/recovery/sender_test.go @@ -0,0 +1,57 @@ +package recovery_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/viper" + + "github.com/ory/kratos/driver/configuration" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/selfservice/flow/verify" +) + +func TestManager(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/extension/schema.json") + viper.Set(configuration.ViperKeyURLsSelfPublic, "https://www.ory.sh/") + viper.Set(configuration.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/") + + t.Run("method=SendCode", func(t *testing.T) { + i := identity.NewIdentity(configuration.DefaultIdentityTraitsSchemaID) + + address, err := identity.NewVerifiableEmailAddress("tracked@ory.sh", i.ID, time.Minute) + require.NoError(t, err) + + i.VerifiableAddresses = []identity.VerifiableAddress{*address} + i.Traits = identity.Traits("{}") + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) + + address, err = reg.VerificationSender().SendCode(context.Background(), address.Via, address.Value) + require.NoError(t, err) + + _, err = reg.VerificationSender().SendCode(context.Background(), address.Via, "not-tracked@ory.sh") + require.EqualError(t, err, verify.ErrUnknownAddress.Error()) + + messages, err := reg.CourierPersister().NextMessages(context.Background(), 12) + require.NoError(t, err) + require.Len(t, messages, 2) + + assert.EqualValues(t, address.Value, messages[0].Recipient) + assert.Contains(t, messages[0].Subject, "Please verify") + + assert.Contains(t, messages[0].Body, address.Code) + fromStore, err := reg.Persister().GetIdentity(context.Background(), i.ID) + require.NoError(t, err) + require.Len(t, fromStore.RecoveryAddresses, 1) + assert.Contains(t, messages[0].Body, fromStore.RecoveryAddresses[0].Code) + + assert.EqualValues(t, "not-tracked@ory.sh", messages[1].Recipient) + assert.Contains(t, messages[1].Subject, "tried to verify") + }) +} diff --git a/selfservice/flow/recovery/strategy.go b/selfservice/flow/recovery/strategy.go new file mode 100644 index 000000000000..5e606c247a74 --- /dev/null +++ b/selfservice/flow/recovery/strategy.go @@ -0,0 +1,47 @@ +package recovery + +import ( + "net/http" + + "github.com/pkg/errors" + + "github.com/ory/kratos/x" +) + +type Strategy interface { + RecoveryStrategyID() string + RegisterRecoveryRoutes(*x.RouterPublic) + PopulateRecoveryMethod(*http.Request, *Request) error +} + +type Strategies []Strategy + +func (s Strategies) Strategy(id string) (Strategy, error) { + ids := make([]string, len(s)) + for k, ss := range s { + ids[k] = ss.RecoveryStrategyID() + if ss.RecoveryStrategyID() == id { + return ss, nil + } + } + + return nil, errors.Errorf(`unable to find strategy for %s have %v`, id, ids) +} + +func (s Strategies) MustStrategy(id string) Strategy { + strategy, err := s.Strategy(id) + if err != nil { + panic(err) + } + return strategy +} + +func (s Strategies) RegisterPublicRoutes(r *x.RouterPublic) { + for _, ss := range s { + ss.RegisterRecoveryRoutes(r) + } +} + +type StrategyProvider interface { + RecoveryStrategies() Strategies +} diff --git a/selfservice/flow/recovery/strategy_email.go b/selfservice/flow/recovery/strategy_email.go new file mode 100644 index 000000000000..80cc43ad2b0d --- /dev/null +++ b/selfservice/flow/recovery/strategy_email.go @@ -0,0 +1,5 @@ +package recovery + +const ( + StrategyEmail = "profile" +) diff --git a/selfservice/flow/recovery/stub/extension/schema.json b/selfservice/flow/recovery/stub/extension/schema.json new file mode 100644 index 000000000000..9906957f32af --- /dev/null +++ b/selfservice/flow/recovery/stub/extension/schema.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string", + "ory.sh/kratos": { + "verification": { + "via": "email" + } + } + } + }, + "username": { + "type": "string", + "ory.sh/kratos": { + "verification": { + "via": "email" + } + } + } + } +} diff --git a/selfservice/flow/recovery/stub/identity.schema.json b/selfservice/flow/recovery/stub/identity.schema.json new file mode 100644 index 000000000000..22965676cdc0 --- /dev/null +++ b/selfservice/flow/recovery/stub/identity.schema.json @@ -0,0 +1,38 @@ +{ + "$id": "https://example.com/registration.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "email": { + "type": "string", + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + } + }, + "verification": { + "via": "email" + } + } + }, + "stringy": { + "type": "string" + }, + "numby": { + "type": "number" + }, + "booly": { + "type": "boolean" + }, + "should_big_number": { + "type": "number", + "minimum": 1200 + }, + "should_long_string": { + "type": "string", + "minLength": 25 + } + } +} diff --git a/selfservice/flow/registration/handler_test.go b/selfservice/flow/registration/handler_test.go index 5c9ed39628b3..115aa61586f2 100644 --- a/selfservice/flow/registration/handler_test.go +++ b/selfservice/flow/registration/handler_test.go @@ -30,19 +30,10 @@ func TestHandlerRedirectOnAuthenticated(t *testing.T) { conf, reg := internal.NewFastRegistryWithMocks(t) router := x.NewRouterPublic() - reg.RegistrationHandler().RegisterPublicRoutes(router) - reg.RegistrationStrategies().RegisterPublicRoutes(router) - ts := httptest.NewServer(router) - defer ts.Close() + ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, x.NewRouterAdmin()) - redirTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("already authenticated")) - })) - defer redirTS.Close() - - viper.Set(configuration.ViperKeyURLsDefaultReturnTo, redirTS.URL) - viper.Set(configuration.ViperKeyURLsSelfPublic, ts.URL) - viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/registration.schema.json") + testhelpers.NewRedirTS(t, "already authenticated") + viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/identity.schema.json") t.Run("does redirect to default on authenticated request", func(t *testing.T) { body, _ := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router.Router, x.NewTestHTTPRequest(t, "GET", ts.URL+registration.BrowserRegistrationPath, nil)) diff --git a/selfservice/flow/registration/stub/identity.schema.json b/selfservice/flow/registration/stub/identity.schema.json new file mode 100644 index 000000000000..22965676cdc0 --- /dev/null +++ b/selfservice/flow/registration/stub/identity.schema.json @@ -0,0 +1,38 @@ +{ + "$id": "https://example.com/registration.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "email": { + "type": "string", + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + } + }, + "verification": { + "via": "email" + } + } + }, + "stringy": { + "type": "string" + }, + "numby": { + "type": "number" + }, + "booly": { + "type": "boolean" + }, + "should_big_number": { + "type": "number", + "minimum": 1200 + }, + "should_long_string": { + "type": "string", + "minLength": 25 + } + } +} diff --git a/selfservice/flow/settings/handler.go b/selfservice/flow/settings/handler.go index 16d4ba94f446..acbd942127fc 100644 --- a/selfservice/flow/settings/handler.go +++ b/selfservice/flow/settings/handler.go @@ -125,11 +125,11 @@ func (h *Handler) initUpdateSettings(w http.ResponseWriter, r *http.Request, ps // nolint:deadcode,unused // swagger:parameters getSelfServiceBrowserSettingsRequest -type getSelfServiceBrowserLoginRequestParameters struct { +type getSelfServiceBrowserSettingsRequestParameters struct { // Request is the Login Request ID // // The value for this parameter comes from `request` URL Query parameter sent to your - // application (e.g. `/login?request=abcde`). + // application (e.g. `/settingss?request=abcde`). // // required: true // in: query diff --git a/selfservice/flow/settings/request.go b/selfservice/flow/settings/request.go index 3e17bf7eb34f..37e0cd078598 100644 --- a/selfservice/flow/settings/request.go +++ b/selfservice/flow/settings/request.go @@ -69,7 +69,7 @@ type Request struct { // required: true Identity *identity.Identity `json:"identity" faker:"identity" db:"-" belongs_to:"identities" fk_id:"IdentityID"` - // UpdateSuccessful, if true, indicates that the settings request has been updated successfully with the provided data. + // Success, if true, indicates that the settings request has been updated successfully with the provided data. // Done will stay true when repeatedly checking. If set to true, done will revert back to false only // when a request with invalid (e.g. "please use a valid phone number") data was sent. // @@ -131,7 +131,7 @@ func (r *Request) AfterSave(c *pop.Connection) error { } func (r *Request) AfterFind(_ *pop.Connection) error { - r.Methods = make(RequestForms) + r.Methods = make(RequestMethods) for key := range r.MethodsRaw { m := r.MethodsRaw[key] // required for pointer dereference r.Methods[m.Method] = &m diff --git a/selfservice/flow/settings/request_method.go b/selfservice/flow/settings/request_method.go index 594d6884c91e..7e49a9cc4823 100644 --- a/selfservice/flow/settings/request_method.go +++ b/selfservice/flow/settings/request_method.go @@ -41,9 +41,9 @@ func (u RequestMethod) TableName() string { } type RequestMethodsRaw []RequestMethod // workaround for https://github.com/gobuffalo/pop/pull/478 -type RequestForms map[string]*RequestMethod +type RequestMethods map[string]*RequestMethod -func (u RequestForms) TableName() string { +func (u RequestMethods) TableName() string { // This must be stay a value receiver, using a pointer receiver will cause issues with pop. return "selfservice_settings_request_methods" } diff --git a/selfservice/flow/settings/strategy_profile_test.go b/selfservice/flow/settings/strategy_profile_test.go index 07dc228af125..0c5700d08483 100644 --- a/selfservice/flow/settings/strategy_profile_test.go +++ b/selfservice/flow/settings/strategy_profile_test.go @@ -48,9 +48,9 @@ func TestStrategyTraits(t *testing.T) { Credentials: map[identity.CredentialsType]identity.Credentials{ "password": {Type: "password", Identifiers: []string{"john@doe.com"}, Config: json.RawMessage(`{"hashed_password":"foo"}`)}, }, - Traits: identity.Traits(`{"email":"john@doe.com","stringy":"foobar","booly":false,"numby":2.5,"should_long_string":"asdfasdfasdfasdfasfdasdfasdfasdf","should_big_number":2048}`), - TraitsSchemaID: configuration.DefaultIdentityTraitsSchemaID, - Addresses: []identity.VerifiableAddress{{Value: "john@doe.com", Via: identity.VerifiableAddressTypeEmail}}, + Traits: identity.Traits(`{"email":"john@doe.com","stringy":"foobar","booly":false,"numby":2.5,"should_long_string":"asdfasdfasdfasdfasfdasdfasdfasdf","should_big_number":2048}`), + TraitsSchemaID: configuration.DefaultIdentityTraitsSchemaID, + VerifiableAddresses: []identity.VerifiableAddress{{Value: "john@doe.com", Via: identity.VerifiableAddressTypeEmail}}, } publicTS, adminTS, clients := testhelpers.NewSettingsAPIServer(t, reg, map[string]*identity.Identity{ "primary": primaryIdentity, diff --git a/selfservice/flow/verify/error.go b/selfservice/flow/verify/error.go index 569287729e5c..1c5933d47dcc 100644 --- a/selfservice/flow/verify/error.go +++ b/selfservice/flow/verify/error.go @@ -77,7 +77,7 @@ func (s *ErrorHandler) HandleVerificationError( ) a.Form.AddError(&form.Error{Message: e.ReasonField}) - if err := s.d.VerificationPersister().CreateVerifyRequest(r.Context(), a); err != nil { + if err := s.d.VerificationPersister().CreateVerificationRequest(r.Context(), a); err != nil { s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err) return } @@ -94,7 +94,7 @@ func (s *ErrorHandler) HandleVerificationError( return } - if err := s.d.VerificationPersister().UpdateVerifyRequest(r.Context(), rr); err != nil { + if err := s.d.VerificationPersister().UpdateVerificationRequest(r.Context(), rr); err != nil { s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err) return } diff --git a/selfservice/flow/verify/handler.go b/selfservice/flow/verify/handler.go index f8a57bca1d91..45eee16094a3 100644 --- a/selfservice/flow/verify/handler.go +++ b/selfservice/flow/verify/handler.go @@ -109,7 +109,7 @@ func (h *Handler) init(w http.ResponseWriter, r *http.Request, ps httprouter.Par urlx.AppendPaths(h.c.SelfPublicURL(), strings.ReplaceAll(PublicVerificationCompletePath, ":via", string(via))), h.d.GenerateCSRFToken, ) - if err := h.d.VerificationPersister().CreateVerifyRequest(r.Context(), a); err != nil { + if err := h.d.VerificationPersister().CreateVerificationRequest(r.Context(), a); err != nil { h.handleError(w, r, nil, err) return } @@ -169,7 +169,7 @@ func (h *Handler) adminFetch(w http.ResponseWriter, r *http.Request, _ httproute func (h *Handler) fetch(w http.ResponseWriter, r *http.Request, mustVerify bool) error { rid := x.ParseUUID(r.URL.Query().Get("request")) - ar, err := h.d.VerificationPersister().GetVerifyRequest(r.Context(), rid) + ar, err := h.d.VerificationPersister().GetVerificationRequest(r.Context(), rid) if err != nil { return err } @@ -243,7 +243,7 @@ func (h *Handler) complete(w http.ResponseWriter, r *http.Request, ps httprouter return } - vr, err := h.d.VerificationPersister().GetVerifyRequest(r.Context(), x.ParseUUID(rid)) + vr, err := h.d.VerificationPersister().GetVerificationRequest(r.Context(), x.ParseUUID(rid)) if err != nil { h.handleError(w, r, vr, err) return @@ -285,7 +285,7 @@ func (h *Handler) completeViaEmail(w http.ResponseWriter, r *http.Request, vr *R vr.Form = nil vr.Success = true - if err := h.d.VerificationPersister().UpdateVerifyRequest(r.Context(), vr); err != nil { + if err := h.d.VerificationPersister().UpdateVerificationRequest(r.Context(), vr); err != nil { h.handleError(w, r, vr, err) return } @@ -343,7 +343,7 @@ func (h *Handler) verify(w http.ResponseWriter, r *http.Request, ps httprouter.P ) a.Form.AddError(&form.Error{Message: "The verification code has expired or was otherwise invalid. Please request another code."}) - if err := h.d.VerificationPersister().CreateVerifyRequest(r.Context(), a); err != nil { + if err := h.d.VerificationPersister().CreateVerificationRequest(r.Context(), a); err != nil { h.handleError(w, r, nil, err) return } diff --git a/selfservice/flow/verify/handler_test.go b/selfservice/flow/verify/handler_test.go index 5b9541ceb23f..475e08751e39 100644 --- a/selfservice/flow/verify/handler_test.go +++ b/selfservice/flow/verify/handler_test.go @@ -147,7 +147,7 @@ func TestHandler(t *testing.T) { stubIdentity.Traits = identity.Traits(`{"emails":["exists@ory.sh"]}`) address, err := identity.NewVerifiableEmailAddress("exists@ory.sh", stubIdentity.ID, time.Minute) require.NoError(t, err) - stubIdentity.Addresses = append(stubIdentity.Addresses, *address) + stubIdentity.VerifiableAddresses = append(stubIdentity.VerifiableAddresses, *address) require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &stubIdentity)) for name, tc := range map[string]struct { @@ -231,10 +231,10 @@ func TestHandler(t *testing.T) { hc := &http.Client{Jar: x.EasyCookieJar(t, nil)} rid := string(x.EasyGetBody(t, hc, initURL)) - vr, err := reg.VerificationPersister().GetVerifyRequest(context.Background(), x.ParseUUID(rid)) + vr, err := reg.VerificationPersister().GetVerificationRequest(context.Background(), x.ParseUUID(rid)) require.NoError(t, err) vr.ExpiresAt = time.Now().Add(-time.Minute) - require.NoError(t, reg.VerificationPersister().UpdateVerifyRequest(context.Background(), vr)) + require.NoError(t, reg.VerificationPersister().UpdateVerificationRequest(context.Background(), vr)) svr, err := adminClient.Common.GetSelfServiceVerificationRequest(common. NewGetSelfServiceVerificationRequestParams().WithRequest(rid)) diff --git a/selfservice/flow/verify/persistence.go b/selfservice/flow/verify/persistence.go index 059c0bcbfefe..999f11896f54 100644 --- a/selfservice/flow/verify/persistence.go +++ b/selfservice/flow/verify/persistence.go @@ -25,9 +25,9 @@ type ( VerificationPersister() Persister } Persister interface { - CreateVerifyRequest(context.Context, *Request) error - GetVerifyRequest(ctx context.Context, id uuid.UUID) (*Request, error) - UpdateVerifyRequest(context.Context, *Request) error + CreateVerificationRequest(context.Context, *Request) error + GetVerificationRequest(ctx context.Context, id uuid.UUID) (*Request, error) + UpdateVerificationRequest(context.Context, *Request) error } ) @@ -39,7 +39,7 @@ func TestPersister(p interface { return func(t *testing.T) { t.Run("suite=request", func(t *testing.T) { t.Run("case=should error when the verify does not exist", func(t *testing.T) { - _, err := p.GetVerifyRequest(context.Background(), x.NewUUID()) + _, err := p.GetVerificationRequest(context.Background(), x.NewUUID()) require.Equal(t, errorsx.Cause(err), sqlcon.ErrNoRows) }) @@ -58,9 +58,9 @@ func TestPersister(p interface { t.Run("case=should create and fetch verify request", func(t *testing.T) { expected := newRequest(t) expected.Form = form.NewHTMLForm("some/action") - err := p.CreateVerifyRequest(context.Background(), expected) + err := p.CreateVerificationRequest(context.Background(), expected) require.NoError(t, err, "%#v", err) - actual, err := p.GetVerifyRequest(context.Background(), expected.ID) + actual, err := p.GetVerificationRequest(context.Background(), expected.ID) require.NoError(t, err) factual, err := json.Marshal(actual.Form) @@ -81,14 +81,14 @@ func TestPersister(p interface { t.Run("case=should create and update a verify request", func(t *testing.T) { expected := newRequest(t) expected.Form = form.NewHTMLForm("some/action") - err := p.CreateVerifyRequest(context.Background(), expected) + err := p.CreateVerificationRequest(context.Background(), expected) require.NoError(t, err) expected.Form.Action = "/new-action" expected.RequestURL = "/new-request-url" - require.NoError(t, p.UpdateVerifyRequest(context.Background(), expected)) + require.NoError(t, p.UpdateVerificationRequest(context.Background(), expected)) - actual, err := p.GetVerifyRequest(context.Background(), expected.ID) + actual, err := p.GetVerificationRequest(context.Background(), expected.ID) require.NoError(t, err) assert.Equal(t, "/new-action", actual.Form.Action) diff --git a/selfservice/flow/verify/sender_test.go b/selfservice/flow/verify/sender_test.go index e19bdf002a53..351f49c6a18d 100644 --- a/selfservice/flow/verify/sender_test.go +++ b/selfservice/flow/verify/sender_test.go @@ -28,7 +28,7 @@ func TestManager(t *testing.T) { address, err := identity.NewVerifiableEmailAddress("tracked@ory.sh", i.ID, time.Minute) require.NoError(t, err) - i.Addresses = []identity.VerifiableAddress{*address} + i.VerifiableAddresses = []identity.VerifiableAddress{*address} i.Traits = identity.Traits("{}") require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) @@ -48,7 +48,7 @@ func TestManager(t *testing.T) { assert.Contains(t, messages[0].Body, address.Code) fromStore, err := reg.Persister().GetIdentity(context.Background(), i.ID) require.NoError(t, err) - assert.Contains(t, messages[0].Body, fromStore.Addresses[0].Code) + assert.Contains(t, messages[0].Body, fromStore.VerifiableAddresses[0].Code) assert.EqualValues(t, "not-tracked@ory.sh", messages[1].Recipient) assert.Contains(t, messages[1].Subject, "tried to verify") diff --git a/selfservice/hook/verify.go b/selfservice/hook/verify.go index 6e3fdfd9c118..ceaebcfed088 100644 --- a/selfservice/hook/verify.go +++ b/selfservice/hook/verify.go @@ -38,12 +38,12 @@ func (e *Verifier) do(r *http.Request, i *identity.Identity) error { // Ths is called after the identity has been created so we can safely assume that all addresses are available // already. - for k, address := range i.Addresses { + for k, address := range i.VerifiableAddresses { sent, err := e.r.VerificationSender().SendCode(r.Context(), address.Via, address.Value) if err != nil { return err } - i.Addresses[k] = *sent + i.VerifiableAddresses[k] = *sent } return nil diff --git a/selfservice/strategy/password/login_test.go b/selfservice/strategy/password/login_test.go index a9fe2d0af17d..e25418703bda 100644 --- a/selfservice/strategy/password/login_test.go +++ b/selfservice/strategy/password/login_test.go @@ -7,7 +7,6 @@ import ( "io/ioutil" "net/http" "net/http/cookiejar" - "net/http/httptest" "net/url" "strings" "testing" @@ -77,27 +76,18 @@ func nlr(exp time.Duration) *login.Request { func TestLoginNew(t *testing.T) { _, reg := internal.NewFastRegistryWithMocks(t) - router := x.NewRouterPublic() - admin := x.NewRouterAdmin() + ts, _ := testhelpers.NewKratosServer(t,reg) - reg.LoginHandler().RegisterPublicRoutes(router) - reg.LoginHandler().RegisterAdminRoutes(admin) - reg.LoginStrategies().MustStrategy(identity.CredentialsTypePassword).(*password.Strategy).RegisterLoginRoutes(router) - - ts := httptest.NewServer(router) - defer ts.Close() - - errTs, uiTs, returnTs := testhelpers.NewErrorTestServer(t, reg), httptest.NewServer(login.TestRequestHandler(t, reg)), newReturnTs(t, reg) - defer errTs.Close() - defer uiTs.Close() - defer returnTs.Close() + errTs := testhelpers.NewErrorTestServer(t, reg) + uiTs := testhelpers.NewLoginUIRequestEchoServer(t, reg) + newReturnTs(t, reg) + // Overwrite these two: viper.Set(configuration.ViperKeyURLsError, errTs.URL+"/error-ts") viper.Set(configuration.ViperKeyURLsLogin, uiTs.URL+"/login-ts") - viper.Set(configuration.ViperKeyURLsSelfPublic, ts.URL) + viper.Set(configuration.ViperKeyDefaultIdentityTraitsSchemaURL, "file://./stub/login.schema.json") viper.Set(configuration.ViperKeySecretsSession, []string{"not-a-secure-session-key"}) - viper.Set(configuration.ViperKeyURLsDefaultReturnTo, returnTs.URL+"/return-ts") mr := func(t *testing.T, payload string, requestID string, c *http.Client) (*http.Response, []byte) { res, err := c.Post(ts.URL+password.LoginPath+"?request="+requestID, "application/x-www-form-urlencoded", strings.NewReader(payload)) diff --git a/selfservice/strategy/password/strategy_test.go b/selfservice/strategy/password/strategy_test.go index 9a26b1d8a195..867a606ba85a 100644 --- a/selfservice/strategy/password/strategy_test.go +++ b/selfservice/strategy/password/strategy_test.go @@ -10,6 +10,9 @@ import ( "github.com/bmizerany/assert" "github.com/stretchr/testify/require" + "github.com/ory/viper" + + "github.com/ory/kratos/driver/configuration" "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" "github.com/ory/kratos/selfservice/errorx" @@ -33,11 +36,14 @@ func newReturnTs(t *testing.T, reg interface { session.ManagementProvider x.WriterProvider }) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, err := reg.SessionManager().FetchFromRequest(r.Context(), r) require.NoError(t, err) reg.Writer().Write(w, r, sess) })) + t.Cleanup(ts.Close) + viper.Set(configuration.ViperKeyURLsDefaultReturnTo, ts.URL+"/return-ts") + return ts } func TestCountActiveCredentials(t *testing.T) { diff --git a/x/nosurf.go b/x/nosurf.go index fb6a9dbbc29c..25ff5e68b654 100644 --- a/x/nosurf.go +++ b/x/nosurf.go @@ -103,12 +103,7 @@ func NewTestCSRFHandler(router http.Handler, reg interface { WithCSRFTokenGenerator(CSRFToken) }) *nosurf.CSRFHandler { n := nosurf.New(router) - n.SetBaseCookie(http.Cookie{ - MaxAge: nosurf.MaxAge, - Path: "/", - HttpOnly: true, - Secure: false, - }) + n.SetBaseCookie(http.Cookie{MaxAge: nosurf.MaxAge, Path: "/", HttpOnly: true, Secure: false}) reg.WithCSRFHandler(n) reg.WithCSRFTokenGenerator(nosurf.Token) return n