diff --git a/embedx/identity_extension.schema.json b/embedx/identity_extension.schema.json index 4d1c67ffcc0..7205039be9a 100644 --- a/embedx/identity_extension.schema.json +++ b/embedx/identity_extension.schema.json @@ -39,7 +39,8 @@ "via": { "type": "string", "enum": [ - "email" + "email", + "phone" ] } } diff --git a/go.mod b/go.mod index b4d5001dc9d..5d894b14ece 100644 --- a/go.mod +++ b/go.mod @@ -72,7 +72,7 @@ require ( github.com/ory/go-convenience v0.1.0 github.com/ory/graceful v0.1.1 github.com/ory/herodot v0.9.12 - github.com/ory/jsonschema/v3 v3.0.4 + github.com/ory/jsonschema/v3 v3.0.5-0.20211222152031-b530fb44a010 github.com/ory/kratos-client-go v0.6.3-alpha.1 github.com/ory/mail/v3 v3.0.0 github.com/ory/nosurf v1.2.6 diff --git a/go.sum b/go.sum index d0e7cfe64ab..bdaade12487 100644 --- a/go.sum +++ b/go.sum @@ -1467,6 +1467,8 @@ github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nyaruka/phonenumbers v1.0.73 h1:bP2WN8/NUP8tQebR+WCIejFaibwYMHOaB7MQVayclUo= +github.com/nyaruka/phonenumbers v1.0.73/go.mod h1:3aiS+PS3DuYwkbK3xdcmRwMiPNECZ0oENH8qUT1lY7Q= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= @@ -1572,6 +1574,8 @@ github.com/ory/jsonschema/v3 v3.0.1/go.mod h1:jgLHekkFk0uiGdEWGleC+tOm6JSSP8cbf1 github.com/ory/jsonschema/v3 v3.0.3/go.mod h1:JvXwbx7IxAkIAo7Qo5OSC1lea+w12DtYGV8h+MTAfnA= github.com/ory/jsonschema/v3 v3.0.4 h1:xI5eEjhl7p8aU2568esHv1t5jpKIHvaEII+hlBt7dnQ= github.com/ory/jsonschema/v3 v3.0.4/go.mod h1:lC4vfZfOalFjz1P1bSHcXbCQXbLjrKvTfX83SmyU6BU= +github.com/ory/jsonschema/v3 v3.0.5-0.20211222152031-b530fb44a010 h1:EAoq3pyp2tfTHJpfHTruF7mfGDtpiOiLgMTM/oG4dyA= +github.com/ory/jsonschema/v3 v3.0.5-0.20211222152031-b530fb44a010/go.mod h1:BYoDlVglsc+iEG4x+dH3GGz850bOI5ZnokRF6xorA9w= github.com/ory/mail v2.3.1+incompatible h1:vHntHDHtQXamt2T+iwTTlCoBkDvILUeujE9Ocwe9md4= github.com/ory/mail v2.3.1+incompatible/go.mod h1:87D9/1gB6ewElQoN0lXJ0ayfqcj3cW3qCTXh+5E9mfU= github.com/ory/mail/v3 v3.0.0 h1:8LFMRj473vGahFD/ntiotWEd4S80FKYFtiZTDfOQ+sM= diff --git a/identity/address.go b/identity/address.go index 4a8c5d98d68..d4b4713eceb 100644 --- a/identity/address.go +++ b/identity/address.go @@ -1,3 +1,6 @@ package identity -const AddressTypeEmail = "email" +const ( + AddressTypeEmail = "email" + AddressTypePhone = "phone" +) diff --git a/identity/extension_verify.go b/identity/extension_verify.go index 7afcc9947d6..b900e7d030d 100644 --- a/identity/extension_verify.go +++ b/identity/extension_verify.go @@ -6,7 +6,6 @@ import ( "time" "github.com/ory/jsonschema/v3" - "github.com/ory/kratos/schema" ) @@ -26,25 +25,28 @@ func (r *SchemaExtensionVerification) Run(ctx jsonschema.ValidationContext, s sc defer r.l.Unlock() switch s.Verification.Via { - case "email": + case AddressTypeEmail: if !jsonschema.Formats["email"](value) { return ctx.Error("format", "%q is not valid %q", value, "email") } address := NewVerifiableEmailAddress(fmt.Sprintf("%s", value), r.i.ID) - if has := r.has(r.i.VerifiableAddresses, address); has != nil { - if r.has(r.v, address) == nil { - r.v = append(r.v, *has) - } - return nil - } + r.appendAddress(address) + + return nil - if has := r.has(r.v, address); has == nil { - r.v = append(r.v, *address) + case AddressTypePhone: + if !jsonschema.Formats["tel"](value) { + return ctx.Error("format", "%q is not valid %q", value, "phone") } + address := NewVerifiablePhoneAddress(fmt.Sprintf("%s", value), r.i.ID) + + r.appendAddress(address) + return nil + case "": return nil } @@ -52,7 +54,25 @@ func (r *SchemaExtensionVerification) Run(ctx jsonschema.ValidationContext, s sc return ctx.Error("", "verification.via has unknown value %q", s.Verification.Via) } -func (r *SchemaExtensionVerification) has(haystack []VerifiableAddress, needle *VerifiableAddress) *VerifiableAddress { +func (r *SchemaExtensionVerification) Finish() error { + r.i.VerifiableAddresses = r.v + return nil +} + +func (r *SchemaExtensionVerification) appendAddress(address *VerifiableAddress) { + if h := has(r.i.VerifiableAddresses, address); h != nil { + if has(r.v, address) == nil { + r.v = append(r.v, *h) + } + return + } + + if has(r.v, address) == nil { + r.v = append(r.v, *address) + } +} + +func has(haystack []VerifiableAddress, needle *VerifiableAddress) *VerifiableAddress { for _, has := range haystack { if has.Value == needle.Value && has.Via == needle.Via { return &has @@ -60,8 +80,3 @@ func (r *SchemaExtensionVerification) has(haystack []VerifiableAddress, needle * } return nil } - -func (r *SchemaExtensionVerification) Finish() error { - r.i.VerifiableAddresses = r.v - return nil -} diff --git a/identity/extension_verify_test.go b/identity/extension_verify_test.go index 3c3aef24515..c98a8f49b24 100644 --- a/identity/extension_verify_test.go +++ b/identity/extension_verify_test.go @@ -8,196 +8,361 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ory/jsonschema/v3" _ "github.com/ory/jsonschema/v3/fileloader" - "github.com/ory/kratos/schema" "github.com/ory/kratos/x" +) - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" +const ( + emailSchemaPath = "file://./stub/extension/verify/email.schema.json" + phoneSchemaPath = "file://./stub/extension/verify/phone.schema.json" ) func TestSchemaExtensionVerification(t *testing.T) { - iid := x.NewUUID() - for k, tc := range []struct { - expectErr error - schema string - doc string - expect []VerifiableAddress - existing []VerifiableAddress - }{ - { - doc: `{"username":"foo@ory.sh"}`, - schema: "file://./stub/extension/verify/schema.json", - expect: []VerifiableAddress{ - { - Value: "foo@ory.sh", - Verified: false, - Status: VerifiableAddressStatusPending, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, + t.Run("address verification", func(t *testing.T) { + iid := x.NewUUID() + + for _, tc := range []struct { + name string + schema string + expectErr error + doc string + existing []VerifiableAddress + expect []VerifiableAddress + }{ + { + name: "email:must create new address", + schema: emailSchemaPath, + doc: `{"username":"foo@ory.sh"}`, + expect: []VerifiableAddress{ + { + Value: "foo@ory.sh", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + }, + }, + { + name: "email:must create new address because new and existing doesn't match", + schema: emailSchemaPath, + doc: `{"username":"foo@ory.sh"}`, + existing: []VerifiableAddress{ + { + Value: "bar@ory.sh", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + }, + expect: []VerifiableAddress{ + { + Value: "foo@ory.sh", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + }, + }, + { + name: "email:must find existing address in case of match", + schema: emailSchemaPath, + doc: `{"emails":["baz@ory.sh","foo@ory.sh"]}`, + existing: []VerifiableAddress{ + { + Value: "foo@ory.sh", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + { + Value: "bar@ory.sh", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + }, + expect: []VerifiableAddress{ + { + Value: "foo@ory.sh", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + { + Value: "baz@ory.sh", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, }, }, - }, - { - doc: `{"username":"foo@ory.sh"}`, - schema: "file://./stub/extension/verify/schema.json", - expect: []VerifiableAddress{ - { - Value: "foo@ory.sh", - Verified: false, - Status: VerifiableAddressStatusPending, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, + { + name: "email:must return only one address in case of duplication", + schema: emailSchemaPath, + doc: `{"emails":["foo@ory.sh","foo@ory.sh","baz@ory.sh"]}`, + existing: []VerifiableAddress{ + { + Value: "foo@ory.sh", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + { + Value: "bar@ory.sh", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, }, + expect: []VerifiableAddress{ + { + Value: "foo@ory.sh", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + { + Value: "baz@ory.sh", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + }, + }, + { + name: "email:must return error for malformed input", + schema: emailSchemaPath, + doc: `{"emails":["foo@ory.sh","bar@ory.sh"], "username": "foobar"}`, + expectErr: errors.New("I[#/username] S[#/properties/username/format] \"foobar\" is not valid \"email\""), }, - existing: []VerifiableAddress{ - { - Value: "bar@ory.sh", - Verified: false, - Status: VerifiableAddressStatusPending, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, + { + name: "email:must merge email addresses from multiple attributes", + schema: emailSchemaPath, + doc: `{"emails":["foo@ory.sh","bar@ory.sh","bar@ory.sh"], "username": "foobar@ory.sh"}`, + expect: []VerifiableAddress{ + { + Value: "foo@ory.sh", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + { + Value: "bar@ory.sh", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, + { + Value: "foobar@ory.sh", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypeEmail, + IdentityID: iid, + }, }, }, - }, - { - doc: `{"emails":["baz@ory.sh","foo@ory.sh"]}`, - schema: "file://./stub/extension/verify/schema.json", - expect: []VerifiableAddress{ - { - Value: "foo@ory.sh", - Verified: true, - Status: VerifiableAddressStatusCompleted, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, - }, - { - Value: "baz@ory.sh", - Verified: false, - Status: VerifiableAddressStatusPending, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, + { + name: "phone:must create new address", + schema: phoneSchemaPath, + doc: `{"username":"+18004444444"}`, + expect: []VerifiableAddress{ + { + Value: "+18004444444", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, }, }, - existing: []VerifiableAddress{ - { - Value: "foo@ory.sh", - Verified: true, - Status: VerifiableAddressStatusCompleted, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, - }, - { - Value: "bar@ory.sh", - Verified: true, - Status: VerifiableAddressStatusCompleted, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, + { + name: "phone:must create new address because new and existing doesn't match", + schema: phoneSchemaPath, + doc: `{"username":"+18004444444"}`, + existing: []VerifiableAddress{ + { + Value: "+442087599036", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, + }, + expect: []VerifiableAddress{ + { + Value: "+18004444444", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, }, }, - }, - { - doc: `{"emails":["foo@ory.sh","foo@ory.sh","baz@ory.sh"]}`, - schema: "file://./stub/extension/verify/schema.json", - expect: []VerifiableAddress{ - { - Value: "foo@ory.sh", - Verified: true, - Status: VerifiableAddressStatusCompleted, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, - }, - { - Value: "baz@ory.sh", - Verified: false, - Status: VerifiableAddressStatusPending, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, + { + name: "phone:must find existing addresses in case of match", + schema: phoneSchemaPath, + doc: `{"phones":["+18004444444","+442087599036"]}`, + existing: []VerifiableAddress{ + { + Value: "+442087599036", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, + { + Value: "+380634872774", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, + }, + expect: []VerifiableAddress{ + { + Value: "+442087599036", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, + { + Value: "+18004444444", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, }, }, - existing: []VerifiableAddress{ - { - Value: "foo@ory.sh", - Verified: true, - Status: VerifiableAddressStatusCompleted, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, - }, - { - Value: "bar@ory.sh", - Verified: true, - Status: VerifiableAddressStatusCompleted, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, + { + name: "phone:must return only one address in case of duplication", + schema: phoneSchemaPath, + doc: `{"phones": ["+18004444444","+18004444444","+442087599036"]}`, + existing: []VerifiableAddress{ + { + Value: "+18004444444", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, + { + Value: "+380634872774", + Verified: true, + Status: VerifiableAddressStatusCompleted, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, + }, + expect: []VerifiableAddress{ + { + Value: "+18004444444", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, + { + Value: "+442087599036", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, }, }, - }, - { - doc: `{"emails":["foo@ory.sh","bar@ory.sh"], "username": "foobar"}`, - schema: "file://./stub/extension/verify/schema.json", - expectErr: errors.New("I[#/username] S[#/properties/username/format] \"foobar\" is not valid \"email\""), - }, - { - doc: `{"emails":["foo@ory.sh","bar@ory.sh","bar@ory.sh"], "username": "foobar@ory.sh"}`, - schema: "file://./stub/extension/verify/schema.json", - expect: []VerifiableAddress{ - { - Value: "foo@ory.sh", - Verified: false, - Status: VerifiableAddressStatusPending, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, - }, - { - Value: "bar@ory.sh", - Verified: false, - Status: VerifiableAddressStatusPending, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, - }, - { - Value: "foobar@ory.sh", - Verified: false, - Status: VerifiableAddressStatusPending, - Via: VerifiableAddressTypeEmail, - IdentityID: iid, + { + name: "phone:must merge phones from multiple attributes", + schema: phoneSchemaPath, + doc: `{"phones": ["+18004444444","+18004444444","+442087599036"], "username": "+380634872774"}`, + expect: []VerifiableAddress{ + { + Value: "+18004444444", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, + { + Value: "+442087599036", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, + { + Value: "+380634872774", + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: iid, + }, }, }, - }, - } { - t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { - id := &Identity{ID: iid, VerifiableAddresses: tc.existing} - c := jsonschema.NewCompiler() - runner, err := schema.NewExtensionRunner() - require.NoError(t, err) - - const expiresAt = time.Minute - e := NewSchemaExtensionVerification(id, time.Minute) - runner.AddRunner(e).Register(c) + { + name: "phone:must return error for malformed input", + schema: phoneSchemaPath, + doc: `{"phones":["+18004444444","+18004444444","12112112"], "username": "+380634872774"}`, + expectErr: errors.New("I[#/phones/2] S[#/properties/phones/items/format] \"12112112\" is not valid \"phone\""), + }, + } { + t.Run(fmt.Sprintf("case=%v", tc.name), func(t *testing.T) { + id := &Identity{ID: iid, VerifiableAddresses: tc.existing} - err = c.MustCompile(tc.schema).Validate(bytes.NewBufferString(tc.doc)) - if tc.expectErr != nil { - require.EqualError(t, err, tc.expectErr.Error()) - return - } + c := jsonschema.NewCompiler() - require.NoError(t, e.Finish()) + runner, err := schema.NewExtensionRunner() + require.NoError(t, err) - addresses := id.VerifiableAddresses - require.Len(t, addresses, len(tc.expect)) + e := NewSchemaExtensionVerification(id, time.Minute) + runner.AddRunner(e).Register(c) - for _, actual := range addresses { - var found bool - for _, expect := range tc.expect { - if reflect.DeepEqual(actual, expect) { - found = true - break - } + err = c.MustCompile(tc.schema).Validate(bytes.NewBufferString(tc.doc)) + if tc.expectErr != nil { + require.EqualError(t, err, tc.expectErr.Error()) + return } - assert.True(t, found, "%+v not in %+v", actual, tc.expect) + + require.NoError(t, e.Finish()) + + addresses := id.VerifiableAddresses + require.Len(t, addresses, len(tc.expect)) + + mustContainAddress(t, tc.expect, addresses) + }) + } + }) + +} + +func mustContainAddress(t *testing.T, expected, actual []VerifiableAddress) { + for _, act := range actual { + var found bool + for _, expect := range expected { + if reflect.DeepEqual(act, expect) { + found = true + break } - }) + } + assert.True(t, found, "%+v not in %+v", act, expected) } } diff --git a/identity/identity_verification.go b/identity/identity_verification.go index 5f778930d91..c0e09fb404a 100644 --- a/identity/identity_verification.go +++ b/identity/identity_verification.go @@ -4,15 +4,15 @@ import ( "context" "time" - "github.com/ory/kratos/corp" - "github.com/gofrs/uuid" + "github.com/ory/kratos/corp" "github.com/ory/x/sqlxx" ) const ( VerifiableAddressTypeEmail VerifiableAddressType = AddressTypeEmail + VerifiableAddressTypePhone VerifiableAddressType = AddressTypePhone VerifiableAddressStatusPending VerifiableAddressStatus = "pending" VerifiableAddressStatusSent VerifiableAddressStatus = "sent" @@ -90,6 +90,8 @@ func (v VerifiableAddressType) HTMLFormInputType() string { switch v { case VerifiableAddressTypeEmail: return "email" + case VerifiableAddressTypePhone: + return "phone" } return "" } @@ -108,6 +110,16 @@ func NewVerifiableEmailAddress(value string, identity uuid.UUID) *VerifiableAddr } } +func NewVerifiablePhoneAddress(value string, identity uuid.UUID) *VerifiableAddress { + return &VerifiableAddress{ + Value: value, + Verified: false, + Status: VerifiableAddressStatusPending, + Via: VerifiableAddressTypePhone, + IdentityID: identity, + } +} + func (a VerifiableAddress) GetID() uuid.UUID { return a.ID } diff --git a/identity/stub/extension/verify/schema.json b/identity/stub/extension/verify/email.schema.json similarity index 100% rename from identity/stub/extension/verify/schema.json rename to identity/stub/extension/verify/email.schema.json diff --git a/identity/stub/extension/verify/phone.schema.json b/identity/stub/extension/verify/phone.schema.json new file mode 100644 index 00000000000..2bd7d6f4fa3 --- /dev/null +++ b/identity/stub/extension/verify/phone.schema.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "phones": { + "type": "array", + "items": { + "type": "string", + "ory.sh/kratos": { + "verification": { + "via": "phone" + } + } + } + }, + "username": { + "type": "string", + "ory.sh/kratos": { + "verification": { + "via": "phone" + } + } + } + } +}