From 3b2e9cd122d3ea3d0810385441e64385bde1366e Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Mon, 25 Mar 2024 18:33:34 +0800 Subject: [PATCH 01/10] Add phone normalizer ref #3992 --- pkg/lib/authn/identity/loginid/normalizer.go | 19 +++++++++++++++++++ pkg/lib/authn/stdattrs/normalizer.go | 10 ++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/pkg/lib/authn/identity/loginid/normalizer.go b/pkg/lib/authn/identity/loginid/normalizer.go index 91d2f37fef..9cdd6f1f3a 100644 --- a/pkg/lib/authn/identity/loginid/normalizer.go +++ b/pkg/lib/authn/identity/loginid/normalizer.go @@ -10,6 +10,7 @@ import ( "github.com/authgear/authgear-server/pkg/api/model" "github.com/authgear/authgear-server/pkg/lib/config" + "github.com/authgear/authgear-server/pkg/util/phone" ) type Normalizer interface { @@ -31,6 +32,8 @@ func (f *NormalizerFactory) NormalizerWithLoginIDType(loginIDKeyType model.Login return &UsernameNormalizer{ Config: f.Config.Types.Username, } + case model.LoginIDKeyTypePhone: + return &PhoneNumberNormalizer{} } return &NullNormalizer{} @@ -111,6 +114,22 @@ func (n *UsernameNormalizer) ComputeUniqueKey(normalizeLoginID string) (string, return normalizeLoginID, nil } +type PhoneNumberNormalizer struct { +} + +func (n *PhoneNumberNormalizer) Normalize(loginID string) (string, error) { + e164, err := phone.ParseCombinedPhoneNumber(loginID) + if err != nil { + return "", err + } + + return e164, nil +} + +func (n *PhoneNumberNormalizer) ComputeUniqueKey(normalizeLoginID string) (string, error) { + return normalizeLoginID, nil +} + type NullNormalizer struct{} func (n *NullNormalizer) Normalize(loginID string) (string, error) { diff --git a/pkg/lib/authn/stdattrs/normalizer.go b/pkg/lib/authn/stdattrs/normalizer.go index 65c9e0e9ff..e8b616f5dc 100644 --- a/pkg/lib/authn/stdattrs/normalizer.go +++ b/pkg/lib/authn/stdattrs/normalizer.go @@ -5,7 +5,6 @@ import ( "github.com/authgear/authgear-server/pkg/api/model" "github.com/authgear/authgear-server/pkg/lib/authn/identity/loginid" - "github.com/authgear/authgear-server/pkg/util/phone" "github.com/authgear/authgear-server/pkg/util/validation" ) @@ -51,12 +50,15 @@ func (n *Normalizer) normalizeEmail(t T) error { return nil } -func normalizePhoneNumber(t T) error { +func (n *Normalizer) normalizePhoneNumber(t T) error { if phoneNumber, ok := t[PhoneNumber].(string); ok && phoneNumber != "" { - err := phone.EnsureE164(phoneNumber) + phoneNormalizer := n.LoginIDNormalizerFactory.NormalizerWithLoginIDType(model.LoginIDKeyTypePhone) + phoneNumber, err := phoneNormalizer.Normalize(phoneNumber) if err != nil { return err } + + t[PhoneNumber] = phoneNumber } else { delete(t, PhoneNumber) } @@ -133,7 +135,7 @@ func (n *Normalizer) Normalize(t T) error { return err } - err = normalizePhoneNumber(t) + err = n.normalizePhoneNumber(t) if err != nil { return err } From af4f9d805b4e5482522cf5c79a61915e1e5293b1 Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Tue, 26 Mar 2024 13:12:52 +0800 Subject: [PATCH 02/10] Add test case for phone number normalizer ref #3992 --- .../authn/identity/loginid/normalizer_test.go | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/pkg/lib/authn/identity/loginid/normalizer_test.go b/pkg/lib/authn/identity/loginid/normalizer_test.go index 7855b08965..8210645fa5 100644 --- a/pkg/lib/authn/identity/loginid/normalizer_test.go +++ b/pkg/lib/authn/identity/loginid/normalizer_test.go @@ -164,4 +164,26 @@ func TestNormalizers(t *testing.T) { } }) }) + + Convey("PhoneNumberNormalizer", t, func() { + Convey("normalize to e164", func() { + cases := []Case{ + {"+85298887766", "+85298887766"}, + { + "+852-98887766", + "+85298887766", + }, + { + "+852-98-88-77-66", + "+85298887766", + }, + } + + n := &PhoneNumberNormalizer{} + + for _, c := range cases { + f(c, n) + } + }) + }) } From 063e4556e2d5c45371c9e7b4c389f2d6d9d26ef3 Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Wed, 27 Mar 2024 13:58:29 +0800 Subject: [PATCH 03/10] Add phone LegalParser and LegalAndValidParser ref #3992 --- pkg/util/phone/deps.go | 8 +++ pkg/util/phone/legal_and_valid_parser.go | 73 ++++++++++++++++++++++++ pkg/util/phone/legal_parser.go | 68 ++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 pkg/util/phone/deps.go create mode 100644 pkg/util/phone/legal_and_valid_parser.go create mode 100644 pkg/util/phone/legal_parser.go diff --git a/pkg/util/phone/deps.go b/pkg/util/phone/deps.go new file mode 100644 index 0000000000..b0b6cc34b5 --- /dev/null +++ b/pkg/util/phone/deps.go @@ -0,0 +1,8 @@ +package phone + +import "github.com/google/wire" + +var DependencySet = wire.NewSet( + wire.Struct(new(LegalParser), "*"), + wire.Struct(new(LegalAndValidParser), "*"), +) diff --git a/pkg/util/phone/legal_and_valid_parser.go b/pkg/util/phone/legal_and_valid_parser.go new file mode 100644 index 0000000000..93c66d7225 --- /dev/null +++ b/pkg/util/phone/legal_and_valid_parser.go @@ -0,0 +1,73 @@ +package phone + +import ( + "regexp" + "strconv" + + "github.com/nyaruka/phonenumbers" +) + +// Other places except Login ID normalizer uses LegalAndValidParser. +// What is Legal and Valid? phonenumbers.Parse does not return err, and phonenumbers.IsValidNumber returns true. +type LegalAndValidParser struct{} + +func (p *LegalAndValidParser) ParseInputPhoneNumber(phone string) (e164 string, err error) { + isNumericString, _ := regexp.Match(`^\+[0-9\ \-]*$`, []byte(phone)) + if !isNumericString { + err = ErrNotInE164Format + return + } + num, err := phonenumbers.Parse(phone, "") + if err != nil { + return + } + isPhoneValid := phonenumbers.IsValidNumber(num) + if !isPhoneValid { + err = ErrPhoneNumberInvalid + return + } + e164 = phonenumbers.Format(num, phonenumbers.E164) + return +} + +func (p *LegalAndValidParser) ParseCountryCallingCodeAndNationalNumber(nationalNumber string, countryCallingCode string) (e164 string, err error) { + rawInput := combineCallingCodeWithNumber(nationalNumber, countryCallingCode) + e164, err = p.ParseInputPhoneNumber(rawInput) + return + +} + +func (p *LegalAndValidParser) SplitE164(e164 string) (nationalNumber string, countryCallingCode string, err error) { + err = p.CheckE164(e164) + if err != nil { + return + } + + num, err := phonenumbers.Parse(e164, "") + if err != nil { + return + } + countryCallingCode = strconv.Itoa(int(num.GetCountryCode())) + nationalNumber = phonenumbers.GetNationalSignificantNumber(num) + return +} + +func (p *LegalAndValidParser) CheckE164(phone string) error { + formatted, err := p.ParseInputPhoneNumber(phone) + if err != nil { + return err + } + if formatted != phone { + return ErrNotInE164Format + } + return nil +} + +func (p *LegalAndValidParser) IsNorthAmericaNumber(e164 string) (bool, error) { + _, callingCode, err := p.SplitE164(e164) + if err != nil { + return false, err + } + + return callingCode == "1", nil +} diff --git a/pkg/util/phone/legal_parser.go b/pkg/util/phone/legal_parser.go new file mode 100644 index 0000000000..e049f368be --- /dev/null +++ b/pkg/util/phone/legal_parser.go @@ -0,0 +1,68 @@ +package phone + +import ( + "regexp" + "strconv" + + "github.com/nyaruka/phonenumbers" +) + +// Login ID normalizer uses LegalParser +// What is Legal? phonenumbers.Parse does not return err. +type LegalParser struct{} + +func (p *LegalParser) ParseInputPhoneNumber(phone string) (e164 string, err error) { + isNumericString, _ := regexp.Match(`^\+[0-9\ \-]*$`, []byte(phone)) + if !isNumericString { + err = ErrNotInE164Format + return + } + num, err := phonenumbers.Parse(phone, "") + if err != nil { + return + } + e164 = phonenumbers.Format(num, phonenumbers.E164) + return +} + +func (p *LegalParser) ParseCountryCallingCodeAndNationalNumber(nationalNumber string, countryCallingCode string) (e164 string, err error) { + rawInput := combineCallingCodeWithNumber(nationalNumber, countryCallingCode) + e164, err = p.ParseInputPhoneNumber(rawInput) + return + +} + +func (p *LegalParser) SplitE164(e164 string) (nationalNumber string, countryCallingCode string, err error) { + err = p.CheckE164(e164) + if err != nil { + return + } + + num, err := phonenumbers.Parse(e164, "") + if err != nil { + return + } + countryCallingCode = strconv.Itoa(int(num.GetCountryCode())) + nationalNumber = phonenumbers.GetNationalSignificantNumber(num) + return +} + +func (p *LegalParser) CheckE164(phone string) error { + formatted, err := p.ParseInputPhoneNumber(phone) + if err != nil { + return err + } + if formatted != phone { + return ErrNotInE164Format + } + return nil +} + +func (p *LegalParser) IsNorthAmericaNumber(e164 string) (bool, error) { + _, callingCode, err := p.SplitE164(e164) + if err != nil { + return false, err + } + + return callingCode == "1", nil +} From 4202a77e3e14cd55981b8b6d4c38ed908d658e89 Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Wed, 27 Mar 2024 14:24:54 +0800 Subject: [PATCH 04/10] Replace phone number parsing with suitable parser ref #3992 --- pkg/admin/graphql/user_mutation.go | 3 ++- pkg/auth/webapp/authflow.go | 3 ++- pkg/lib/authn/identity/loginid/normalizer.go | 3 ++- pkg/lib/elasticsearch/model.go | 4 +++- pkg/lib/usage/count_collector.go | 7 +++++-- pkg/util/validation/formats.go | 3 ++- 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pkg/admin/graphql/user_mutation.go b/pkg/admin/graphql/user_mutation.go index c813e3a208..134a6985bf 100644 --- a/pkg/admin/graphql/user_mutation.go +++ b/pkg/admin/graphql/user_mutation.go @@ -261,7 +261,8 @@ var _ = registerMutationField( gqlCtx := GQLContext(p.Context) var channel apimodel.AuthenticatorOOBChannel - if err := phone.EnsureE164(target); err == nil { + phoneNumberParser := &phone.LegalAndValidParser{} + if err := phoneNumberParser.CheckE164(target); err == nil { channel = apimodel.AuthenticatorOOBChannelSMS } else { channel = apimodel.AuthenticatorOOBChannelEmail diff --git a/pkg/auth/webapp/authflow.go b/pkg/auth/webapp/authflow.go index 1dd72414a4..1f50fda3a3 100644 --- a/pkg/auth/webapp/authflow.go +++ b/pkg/auth/webapp/authflow.go @@ -36,8 +36,9 @@ func GetMostAppropriateIdentification(f *authflow.FlowResponse, loginID string, // Else, guess the type + phoneNumberParser := &phone.LegalAndValidParser{} lookLikeAPhoneNumber := func(loginID string) bool { - err := phone.EnsureE164(loginID) + err := phoneNumberParser.CheckE164(loginID) if err == nil { return true } diff --git a/pkg/lib/authn/identity/loginid/normalizer.go b/pkg/lib/authn/identity/loginid/normalizer.go index 9cdd6f1f3a..6404fec745 100644 --- a/pkg/lib/authn/identity/loginid/normalizer.go +++ b/pkg/lib/authn/identity/loginid/normalizer.go @@ -118,7 +118,8 @@ type PhoneNumberNormalizer struct { } func (n *PhoneNumberNormalizer) Normalize(loginID string) (string, error) { - e164, err := phone.ParseCombinedPhoneNumber(loginID) + phoneNumberParser := &phone.LegalParser{} + e164, err := phoneNumberParser.ParseInputPhoneNumber(loginID) if err != nil { return "", err } diff --git a/pkg/lib/elasticsearch/model.go b/pkg/lib/elasticsearch/model.go index 20c00aa875..4ffb57cac2 100644 --- a/pkg/lib/elasticsearch/model.go +++ b/pkg/lib/elasticsearch/model.go @@ -75,10 +75,12 @@ func RawToSource(raw *model.ElasticsearchUserRaw) *model.ElasticsearchUserSource emailDomain = append(emailDomain, domain) } + phoneNumberParser := &phone.LegalAndValidParser{} + var phoneNumberCountryCode []string var phoneNumberNationalNumber []string for _, phoneNumber := range raw.PhoneNumber { - nationalNumber, callingCode, err := phone.ParseE164ToCallingCodeAndNumber(phoneNumber) + nationalNumber, callingCode, err := phoneNumberParser.SplitE164(phoneNumber) if err == nil { phoneNumberCountryCode = append(phoneNumberCountryCode, callingCode) phoneNumberNationalNumber = append(phoneNumberNationalNumber, nationalNumber) diff --git a/pkg/lib/usage/count_collector.go b/pkg/lib/usage/count_collector.go index 0dfa4c7b15..5a412c5fad 100644 --- a/pkg/lib/usage/count_collector.go +++ b/pkg/lib/usage/count_collector.go @@ -300,7 +300,9 @@ func (c *CountCollector) querySMSCount(appID string, rangeFrom *time.Time, range } e164 := payload.Recipient - isNorthAmericaNumber, err := phoneutil.IsNorthAmericaNumber(e164) + + phoneNumberParser := &phoneutil.LegalAndValidParser{} + isNorthAmericaNumber, err := phoneNumberParser.IsNorthAmericaNumber(e164) if err != nil { return nil, fmt.Errorf("usage: failed to parse sms recipient %w", err) } @@ -345,7 +347,8 @@ func (c *CountCollector) queryWhatsappCount(appID string, rangeFrom *time.Time, e164 := payload.Recipient - isNorthAmericaNumber, err := phoneutil.IsNorthAmericaNumber(e164) + phoneNumberParser := &phoneutil.LegalAndValidParser{} + isNorthAmericaNumber, err := phoneNumberParser.IsNorthAmericaNumber(e164) if err != nil { return nil, fmt.Errorf("usage: failed to parse whatsapp recipient %w", err) } diff --git a/pkg/util/validation/formats.go b/pkg/util/validation/formats.go index cfb83730b7..fe4fe78afb 100644 --- a/pkg/util/validation/formats.go +++ b/pkg/util/validation/formats.go @@ -63,7 +63,8 @@ func (f FormatPhone) CheckFormat(value interface{}) error { if !ok { return nil } - return phone.EnsureE164(str) + phoneNumberParser := &phone.LegalAndValidParser{} + return phoneNumberParser.CheckE164(str) } // FormatEmail checks if input is an email address. From b009b15a6ddbca02cbe6c75141195b619a81d5b5 Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Wed, 27 Mar 2024 15:02:45 +0800 Subject: [PATCH 05/10] Remove unused functions ref #3992 --- pkg/util/phone/phone.go | 74 ----------------------------------------- 1 file changed, 74 deletions(-) diff --git a/pkg/util/phone/phone.go b/pkg/util/phone/phone.go index e8d20d0d2d..6d6db5b6d8 100644 --- a/pkg/util/phone/phone.go +++ b/pkg/util/phone/phone.go @@ -3,7 +3,6 @@ package phone import ( "errors" "fmt" - "regexp" "strconv" "strings" @@ -25,70 +24,6 @@ func combineCallingCodeWithNumber(nationalNumber string, callingCode string) str return fmt.Sprintf("%s%s", callingCode, nationalNumber) } -// According to https://godoc.org/github.com/nyaruka/phonenumbers#Parse, -// no validation is performed during parsing, so we need to call IsValidNumber to check -func ParseCombinedPhoneNumber(phone string) (e164 string, err error) { - // check if input contains non-numeric character(s) - // The nationalNumber part of phone is parsed to uint64, - // letters in input phone number will be parsed successfully. - isNumericString, _ := regexp.Match(`^\+[0-9\ \-]*$`, []byte(phone)) - if !isNumericString { - err = ErrNotInE164Format - return - } - num, err := phonenumbers.Parse(phone, "") - if err != nil { - return - } - isPhoneValid := phonenumbers.IsValidNumber(num) - if !isPhoneValid { - err = ErrPhoneNumberInvalid - return - } - e164 = phonenumbers.Format(num, phonenumbers.E164) - return -} - -// ParseE164ToCallingCodeAndNumber parse E.164 format phone number to national number and calling code. -func ParseE164ToCallingCodeAndNumber(e164 string) (nationalNumber string, callingCode string, err error) { - err = EnsureE164(e164) - if err != nil { - return - } - - num, err := phonenumbers.Parse(e164, "") - if err != nil { - return - } - isPhoneValid := phonenumbers.IsValidNumber(num) - if !isPhoneValid { - err = ErrPhoneNumberInvalid - return - } - callingCode = strconv.Itoa(int(num.GetCountryCode())) - nationalNumber = phonenumbers.GetNationalSignificantNumber(num) - return -} - -// Parse to E164 format -func Parse(nationalNumber string, callingCode string) (e164 string, err error) { - rawInput := combineCallingCodeWithNumber(nationalNumber, callingCode) - e164, err = ParseCombinedPhoneNumber(rawInput) - return -} - -// EnsureE164 ensures the given phone is in E.164 format. -func EnsureE164(phone string) error { - formatted, err := ParseCombinedPhoneNumber(phone) - if err != nil { - return err - } - if formatted != phone { - return ErrNotInE164Format - } - return nil -} - // Mask masks the give phone number. func Mask(phone string) string { return MaskWithCustomRune(phone, '*') @@ -120,12 +55,3 @@ func MaskWithCustomRune(phone string, r rune) string { return buf.String() } - -func IsNorthAmericaNumber(e164 string) (bool, error) { - _, callingCode, err := ParseE164ToCallingCodeAndNumber(e164) - if err != nil { - return false, err - } - - return callingCode == "1", nil -} From ea3c53567bed3bf39bf56121c2ccffd1431f6e7a Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Wed, 27 Mar 2024 15:03:32 +0800 Subject: [PATCH 06/10] Add test cases for parsers ref #3992 --- pkg/util/phone/legal_and_valid_parser_test.go | 137 ++++++++++++++++++ pkg/util/phone/legal_parser_test.go | 32 ++++ pkg/util/phone/phone_test.go | 123 ---------------- 3 files changed, 169 insertions(+), 123 deletions(-) create mode 100644 pkg/util/phone/legal_and_valid_parser_test.go create mode 100644 pkg/util/phone/legal_parser_test.go diff --git a/pkg/util/phone/legal_and_valid_parser_test.go b/pkg/util/phone/legal_and_valid_parser_test.go new file mode 100644 index 0000000000..f08aa1df4e --- /dev/null +++ b/pkg/util/phone/legal_and_valid_parser_test.go @@ -0,0 +1,137 @@ +package phone + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestLegalAndValidParser(t *testing.T) { + parser := &LegalAndValidParser{} + Convey("Phone", t, func() { + Convey("checkE164", func() { + good := "+85223456789" + So(parser.CheckE164(good), ShouldBeNil) + + bad := " +85223456789 " + So(parser.CheckE164(bad), ShouldBeError, "not in E.164 format") + + withLetter := "+85222a" + So(parser.CheckE164(withLetter), ShouldBeError, "not in E.164 format") + + invalid := "+85212345678" + So(parser.CheckE164(invalid), ShouldBeError, "invalid phone number") + + tooShort := "+85222" + So(parser.CheckE164(tooShort), ShouldBeError, "invalid phone number") + + nonsense := "a" + So(parser.CheckE164(nonsense), ShouldNotBeNil) + }) + + Convey("ParseCountryCallingCodeAndNationalNumber", func() { + Convey("valid cases", func() { + check := func(nationalNumber, callingCode, e164 string) { + actual, err := parser.ParseCountryCallingCodeAndNationalNumber(nationalNumber, callingCode) + if e164 == "" { + So(err, ShouldNotBeNil) + } else { + So(actual, ShouldEqual, e164) + } + } + // calling code can have optional + sign + check("98887766", "+852", "+85298887766") + check("98887766", "852", "+85298887766") + + // national number can have spaces in between + check("9888 7766", "852", "+85298887766") + check(" 9888 7766 ", "852", "+85298887766") + + // national number can have hyphens in between + check("9888-7766", "852", "+85298887766") + check(" 9888-7766 ", "852", "+85298887766") + check("98-88-77-66", "852", "+85298887766") + check("9-8-8-8-7-7-6-6", "852", "+85298887766") + check("9 - 8 - 8 - 8 - 7 - 7 - 6 - 6 ", "852", "+85298887766") + + // calling code can have leading or trailing spaces + check("98887766", " +852 ", "+85298887766") + check("98887766", " 852 ", "+85298887766") + + // calling code can have spaces or hyphens in between + check("98887766", "8 52", "+85298887766") + check("98887766", "8-52", "+85298887766") + check("98887766", " 8-5-2- ", "+85298887766") + }) + + Convey("should not accept non-numeric character(s)", func() { + _, err := parser.ParseCountryCallingCodeAndNationalNumber("asdf", "+852") + So(err, ShouldBeError, "not in E.164 format") + }) + + Convey("invalid, (+852) phone number does not begin with 1", func() { + _, err := parser.ParseCountryCallingCodeAndNationalNumber("12345678", "+852") + So(err, ShouldBeError, "invalid phone number") + }) + + Convey("invalid, (+852) phone number is 8 digit long", func() { + _, err := parser.ParseCountryCallingCodeAndNationalNumber("6234567", "+852") + So(err, ShouldBeError, "invalid phone number") + }) + }) + + Convey("SplitE164", func() { + Convey("valid cases", func() { + check := func(e164, nationalNumber, callingCode string) { + actualNationalNumber, actualCallingCode, err := parser.SplitE164(e164) + So(actualNationalNumber, ShouldEqual, nationalNumber) + So(actualCallingCode, ShouldEqual, callingCode) + So(err, ShouldBeNil) + } + + check("+61401123456", "401123456", "61") + check("+85298887766", "98887766", "852") + }) + + Convey("invalid, not in E.164 format", func() { + check := func(input string) { + _, _, err := parser.SplitE164(input) + So(err, ShouldBeError, "not in E.164 format") + } + + check("unknown") + check("85298887766") + }) + + Convey("invalid, invalid phone number", func() { + check := func(input string) { + _, _, err := parser.SplitE164(input) + So(err, ShouldBeError, "invalid phone number") + } + + check("+85212345678") + check("+852123456") + }) + }) + + Convey("IsNorthAmericaNumber", func() { + check := func(e164 string, expected bool, errStr string) { + actual, err := parser.IsNorthAmericaNumber(e164) + if errStr == "" { + So(expected, ShouldEqual, actual) + So(err, ShouldBeNil) + } else { + So(err, ShouldBeError, errStr) + } + } + + check("+12015550123", true, "") + check("+18195555555", true, "") + check("+61401123456", false, "") + check("+85298887766", false, "") + check("+85212345678", false, "invalid phone number") + check("+85223456789 ", false, "not in E.164 format") + check("", false, "not in E.164 format") + }) + }) +} diff --git a/pkg/util/phone/legal_parser_test.go b/pkg/util/phone/legal_parser_test.go new file mode 100644 index 0000000000..cbfbb7bb8e --- /dev/null +++ b/pkg/util/phone/legal_parser_test.go @@ -0,0 +1,32 @@ +package phone + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestLegalParser(t *testing.T) { + parser := &LegalParser{} + Convey("Phone", t, func() { + Convey("checkE164", func() { + good := "+85223456789" + So(parser.CheckE164(good), ShouldBeNil) + + bad := " +85223456789 " + So(parser.CheckE164(bad), ShouldBeError, "not in E.164 format") + + withLetter := "+85222a" + So(parser.CheckE164(withLetter), ShouldBeError, "not in E.164 format") + + invalid := "+85212345678" + So(parser.CheckE164(invalid), ShouldBeNil) + + tooShort := "+85222" + So(parser.CheckE164(tooShort), ShouldBeNil) + + nonsense := "a" + So(parser.CheckE164(nonsense), ShouldNotBeNil) + }) + }) +} diff --git a/pkg/util/phone/phone_test.go b/pkg/util/phone/phone_test.go index 68b0307a49..f2fe47fdbd 100644 --- a/pkg/util/phone/phone_test.go +++ b/pkg/util/phone/phone_test.go @@ -8,109 +8,6 @@ import ( func TestPhone(t *testing.T) { Convey("Phone", t, func() { - // EnsureE164 uses same validation logic as Parse - Convey("EnsureE164", func() { - good := "+85223456789" - So(EnsureE164(good), ShouldBeNil) - - bad := " +85223456789 " - So(EnsureE164(bad), ShouldBeError, "not in E.164 format") - - withLetter := "+85222a" - So(EnsureE164(withLetter), ShouldBeError, "not in E.164 format") - - tooShort := "+85222" - So(EnsureE164(tooShort), ShouldBeError, "invalid phone number") - - nonsense := "a" - So(EnsureE164(nonsense), ShouldNotBeNil) - }) - - Convey("Parse", func() { - Convey("valid cases", func() { - check := func(nationalNumber, callingCode, e164 string) { - actual, err := Parse(nationalNumber, callingCode) - if e164 == "" { - So(err, ShouldNotBeNil) - } else { - So(actual, ShouldEqual, e164) - } - } - // calling code can have optional + sign - check("98887766", "+852", "+85298887766") - check("98887766", "852", "+85298887766") - - // national number can have spaces in between - check("9888 7766", "852", "+85298887766") - check(" 9888 7766 ", "852", "+85298887766") - - // national number can have hyphens in between - check("9888-7766", "852", "+85298887766") - check(" 9888-7766 ", "852", "+85298887766") - check("98-88-77-66", "852", "+85298887766") - check("9-8-8-8-7-7-6-6", "852", "+85298887766") - check("9 - 8 - 8 - 8 - 7 - 7 - 6 - 6 ", "852", "+85298887766") - - // calling code can have leading or trailing spaces - check("98887766", " +852 ", "+85298887766") - check("98887766", " 852 ", "+85298887766") - - // calling code can have spaces or hyphens in between - check("98887766", "8 52", "+85298887766") - check("98887766", "8-52", "+85298887766") - check("98887766", " 8-5-2- ", "+85298887766") - }) - - Convey("should not accept non-numeric character(s)", func() { - _, err := Parse("asdf", "+852") - So(err, ShouldBeError, "not in E.164 format") - }) - - Convey("invalid, (+852) phone number does not begin with 1", func() { - _, err := Parse("12345678", "+852") - So(err, ShouldBeError, "invalid phone number") - }) - - Convey("invalid, (+852) phone number is 8 digit long", func() { - _, err := Parse("6234567", "+852") - So(err, ShouldBeError, "invalid phone number") - }) - }) - - Convey("ParseE164ToCallingCodeAndNumber", func() { - Convey("valid cases", func() { - check := func(e164, nationalNumber, callingCode string) { - actualNationalNumber, actualCallingCode, err := ParseE164ToCallingCodeAndNumber(e164) - So(actualNationalNumber, ShouldEqual, nationalNumber) - So(actualCallingCode, ShouldEqual, callingCode) - So(err, ShouldBeNil) - } - - check("+61401123456", "401123456", "61") - check("+85298887766", "98887766", "852") - }) - - Convey("invalid, not in E.164 format", func() { - check := func(input string) { - _, _, err := ParseE164ToCallingCodeAndNumber(input) - So(err, ShouldBeError, "not in E.164 format") - } - - check("unknown") - check("85298887766") - }) - - Convey("invalid, invalid phone number", func() { - check := func(input string) { - _, _, err := ParseE164ToCallingCodeAndNumber(input) - So(err, ShouldBeError, "invalid phone number") - } - - check("+85212345678") - check("+852123456") - }) - }) - Convey("Mask", func() { phone := "+85223456789" So(Mask(phone), ShouldEqual, "+8522345****") @@ -120,25 +17,5 @@ func TestPhone(t *testing.T) { phone := "+85223456789" So(MaskWithCustomRune(phone, 'x'), ShouldEqual, "+8522345xxxx") }) - - Convey("IsNorthAmericaNumber", func() { - check := func(e164 string, expected bool, errStr string) { - actual, err := IsNorthAmericaNumber(e164) - if errStr == "" { - So(expected, ShouldEqual, actual) - So(err, ShouldBeNil) - } else { - So(err, ShouldBeError, errStr) - } - } - - check("+12015550123", true, "") - check("+18195555555", true, "") - check("+61401123456", false, "") - check("+85298887766", false, "") - check("+85212345678", false, "invalid phone number") - check("+85223456789 ", false, "not in E.164 format") - check("", false, "not in E.164 format") - }) }) } From c02b903aa5d0bae45b75c80d1ff0ae57206b3f59 Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Wed, 27 Mar 2024 15:06:44 +0800 Subject: [PATCH 07/10] Use LegalParser for normalizing stdattrs phone number ref #3992 --- pkg/lib/authn/stdattrs/normalizer.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/lib/authn/stdattrs/normalizer.go b/pkg/lib/authn/stdattrs/normalizer.go index e8b616f5dc..9f8b70a0a0 100644 --- a/pkg/lib/authn/stdattrs/normalizer.go +++ b/pkg/lib/authn/stdattrs/normalizer.go @@ -5,6 +5,7 @@ import ( "github.com/authgear/authgear-server/pkg/api/model" "github.com/authgear/authgear-server/pkg/lib/authn/identity/loginid" + "github.com/authgear/authgear-server/pkg/util/phone" "github.com/authgear/authgear-server/pkg/util/validation" ) @@ -52,8 +53,8 @@ func (n *Normalizer) normalizeEmail(t T) error { func (n *Normalizer) normalizePhoneNumber(t T) error { if phoneNumber, ok := t[PhoneNumber].(string); ok && phoneNumber != "" { - phoneNormalizer := n.LoginIDNormalizerFactory.NormalizerWithLoginIDType(model.LoginIDKeyTypePhone) - phoneNumber, err := phoneNormalizer.Normalize(phoneNumber) + phoneNumberParser := &phone.LegalParser{} + phoneNumber, err := phoneNumberParser.ParseInputPhoneNumber(phoneNumber) if err != nil { return err } From 6aa05023fe32eff0621e7afac082ae38eb869cfc Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Thu, 28 Mar 2024 13:09:15 +0800 Subject: [PATCH 08/10] Remove phone number parser from wire dependency graph ref #3992 --- pkg/util/phone/deps.go | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 pkg/util/phone/deps.go diff --git a/pkg/util/phone/deps.go b/pkg/util/phone/deps.go deleted file mode 100644 index b0b6cc34b5..0000000000 --- a/pkg/util/phone/deps.go +++ /dev/null @@ -1,8 +0,0 @@ -package phone - -import "github.com/google/wire" - -var DependencySet = wire.NewSet( - wire.Struct(new(LegalParser), "*"), - wire.Struct(new(LegalAndValidParser), "*"), -) From 9e2891cf5869717daad780d002e7c35030ff13be Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Thu, 28 Mar 2024 13:10:27 +0800 Subject: [PATCH 09/10] Tidy up phone parser comment ref #3992 --- pkg/util/phone/legal_and_valid_parser.go | 3 +-- pkg/util/phone/legal_parser.go | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg/util/phone/legal_and_valid_parser.go b/pkg/util/phone/legal_and_valid_parser.go index 93c66d7225..a56f2b22b6 100644 --- a/pkg/util/phone/legal_and_valid_parser.go +++ b/pkg/util/phone/legal_and_valid_parser.go @@ -7,8 +7,7 @@ import ( "github.com/nyaruka/phonenumbers" ) -// Other places except Login ID normalizer uses LegalAndValidParser. -// What is Legal and Valid? phonenumbers.Parse does not return err, and phonenumbers.IsValidNumber returns true. +// LegalAndValidParser parses a legal and valid phone number. A legal and valid phone number is a phone number that passes phonenumbers.Parse() and phonenumbers.IsValidNumber(). type LegalAndValidParser struct{} func (p *LegalAndValidParser) ParseInputPhoneNumber(phone string) (e164 string, err error) { diff --git a/pkg/util/phone/legal_parser.go b/pkg/util/phone/legal_parser.go index e049f368be..85f34384b3 100644 --- a/pkg/util/phone/legal_parser.go +++ b/pkg/util/phone/legal_parser.go @@ -7,8 +7,7 @@ import ( "github.com/nyaruka/phonenumbers" ) -// Login ID normalizer uses LegalParser -// What is Legal? phonenumbers.Parse does not return err. +// LegalParser parses a legal phone number. A legal phone number is a phone number that passes phonenumbers.Parse(). type LegalParser struct{} func (p *LegalParser) ParseInputPhoneNumber(phone string) (e164 string, err error) { From 95c9ee8c1f96a22dd616848353095abe68a7b892 Mon Sep 17 00:00:00 2001 From: Davis Tang Date: Thu, 28 Mar 2024 13:18:06 +0800 Subject: [PATCH 10/10] Instantiate default instance of phone parser ref #3992 --- pkg/admin/graphql/user_mutation.go | 3 +-- pkg/auth/webapp/authflow.go | 3 +-- pkg/lib/authn/identity/loginid/normalizer.go | 3 +-- pkg/lib/authn/stdattrs/normalizer.go | 3 +-- pkg/lib/elasticsearch/model.go | 4 +--- pkg/lib/usage/count_collector.go | 6 ++---- pkg/util/phone/legal_and_valid_parser.go | 12 ++++++------ pkg/util/phone/legal_and_valid_parser_test.go | 2 +- pkg/util/phone/legal_parser.go | 12 ++++++------ pkg/util/phone/legal_parser_test.go | 2 +- pkg/util/phone/parser.go | 12 ++++++++++++ pkg/util/validation/formats.go | 3 +-- 12 files changed, 34 insertions(+), 31 deletions(-) create mode 100644 pkg/util/phone/parser.go diff --git a/pkg/admin/graphql/user_mutation.go b/pkg/admin/graphql/user_mutation.go index 134a6985bf..d37ed02a5b 100644 --- a/pkg/admin/graphql/user_mutation.go +++ b/pkg/admin/graphql/user_mutation.go @@ -261,8 +261,7 @@ var _ = registerMutationField( gqlCtx := GQLContext(p.Context) var channel apimodel.AuthenticatorOOBChannel - phoneNumberParser := &phone.LegalAndValidParser{} - if err := phoneNumberParser.CheckE164(target); err == nil { + if err := phone.LegalAndValidParser.CheckE164(target); err == nil { channel = apimodel.AuthenticatorOOBChannelSMS } else { channel = apimodel.AuthenticatorOOBChannelEmail diff --git a/pkg/auth/webapp/authflow.go b/pkg/auth/webapp/authflow.go index 1f50fda3a3..cde0654047 100644 --- a/pkg/auth/webapp/authflow.go +++ b/pkg/auth/webapp/authflow.go @@ -36,9 +36,8 @@ func GetMostAppropriateIdentification(f *authflow.FlowResponse, loginID string, // Else, guess the type - phoneNumberParser := &phone.LegalAndValidParser{} lookLikeAPhoneNumber := func(loginID string) bool { - err := phoneNumberParser.CheckE164(loginID) + err := phone.LegalAndValidParser.CheckE164(loginID) if err == nil { return true } diff --git a/pkg/lib/authn/identity/loginid/normalizer.go b/pkg/lib/authn/identity/loginid/normalizer.go index 6404fec745..34d154afde 100644 --- a/pkg/lib/authn/identity/loginid/normalizer.go +++ b/pkg/lib/authn/identity/loginid/normalizer.go @@ -118,8 +118,7 @@ type PhoneNumberNormalizer struct { } func (n *PhoneNumberNormalizer) Normalize(loginID string) (string, error) { - phoneNumberParser := &phone.LegalParser{} - e164, err := phoneNumberParser.ParseInputPhoneNumber(loginID) + e164, err := phone.LegalParser.ParseInputPhoneNumber(loginID) if err != nil { return "", err } diff --git a/pkg/lib/authn/stdattrs/normalizer.go b/pkg/lib/authn/stdattrs/normalizer.go index 9f8b70a0a0..b093799130 100644 --- a/pkg/lib/authn/stdattrs/normalizer.go +++ b/pkg/lib/authn/stdattrs/normalizer.go @@ -53,8 +53,7 @@ func (n *Normalizer) normalizeEmail(t T) error { func (n *Normalizer) normalizePhoneNumber(t T) error { if phoneNumber, ok := t[PhoneNumber].(string); ok && phoneNumber != "" { - phoneNumberParser := &phone.LegalParser{} - phoneNumber, err := phoneNumberParser.ParseInputPhoneNumber(phoneNumber) + phoneNumber, err := phone.LegalParser.ParseInputPhoneNumber(phoneNumber) if err != nil { return err } diff --git a/pkg/lib/elasticsearch/model.go b/pkg/lib/elasticsearch/model.go index 4ffb57cac2..1c300da271 100644 --- a/pkg/lib/elasticsearch/model.go +++ b/pkg/lib/elasticsearch/model.go @@ -75,12 +75,10 @@ func RawToSource(raw *model.ElasticsearchUserRaw) *model.ElasticsearchUserSource emailDomain = append(emailDomain, domain) } - phoneNumberParser := &phone.LegalAndValidParser{} - var phoneNumberCountryCode []string var phoneNumberNationalNumber []string for _, phoneNumber := range raw.PhoneNumber { - nationalNumber, callingCode, err := phoneNumberParser.SplitE164(phoneNumber) + nationalNumber, callingCode, err := phone.LegalAndValidParser.SplitE164(phoneNumber) if err == nil { phoneNumberCountryCode = append(phoneNumberCountryCode, callingCode) phoneNumberNationalNumber = append(phoneNumberNationalNumber, nationalNumber) diff --git a/pkg/lib/usage/count_collector.go b/pkg/lib/usage/count_collector.go index 5a412c5fad..f4d6f071ae 100644 --- a/pkg/lib/usage/count_collector.go +++ b/pkg/lib/usage/count_collector.go @@ -301,8 +301,7 @@ func (c *CountCollector) querySMSCount(appID string, rangeFrom *time.Time, range e164 := payload.Recipient - phoneNumberParser := &phoneutil.LegalAndValidParser{} - isNorthAmericaNumber, err := phoneNumberParser.IsNorthAmericaNumber(e164) + isNorthAmericaNumber, err := phoneutil.LegalAndValidParser.IsNorthAmericaNumber(e164) if err != nil { return nil, fmt.Errorf("usage: failed to parse sms recipient %w", err) } @@ -347,8 +346,7 @@ func (c *CountCollector) queryWhatsappCount(appID string, rangeFrom *time.Time, e164 := payload.Recipient - phoneNumberParser := &phoneutil.LegalAndValidParser{} - isNorthAmericaNumber, err := phoneNumberParser.IsNorthAmericaNumber(e164) + isNorthAmericaNumber, err := phoneutil.LegalAndValidParser.IsNorthAmericaNumber(e164) if err != nil { return nil, fmt.Errorf("usage: failed to parse whatsapp recipient %w", err) } diff --git a/pkg/util/phone/legal_and_valid_parser.go b/pkg/util/phone/legal_and_valid_parser.go index a56f2b22b6..754d2a11eb 100644 --- a/pkg/util/phone/legal_and_valid_parser.go +++ b/pkg/util/phone/legal_and_valid_parser.go @@ -8,9 +8,9 @@ import ( ) // LegalAndValidParser parses a legal and valid phone number. A legal and valid phone number is a phone number that passes phonenumbers.Parse() and phonenumbers.IsValidNumber(). -type LegalAndValidParser struct{} +type legalAndValidParser struct{} -func (p *LegalAndValidParser) ParseInputPhoneNumber(phone string) (e164 string, err error) { +func (p *legalAndValidParser) ParseInputPhoneNumber(phone string) (e164 string, err error) { isNumericString, _ := regexp.Match(`^\+[0-9\ \-]*$`, []byte(phone)) if !isNumericString { err = ErrNotInE164Format @@ -29,14 +29,14 @@ func (p *LegalAndValidParser) ParseInputPhoneNumber(phone string) (e164 string, return } -func (p *LegalAndValidParser) ParseCountryCallingCodeAndNationalNumber(nationalNumber string, countryCallingCode string) (e164 string, err error) { +func (p *legalAndValidParser) ParseCountryCallingCodeAndNationalNumber(nationalNumber string, countryCallingCode string) (e164 string, err error) { rawInput := combineCallingCodeWithNumber(nationalNumber, countryCallingCode) e164, err = p.ParseInputPhoneNumber(rawInput) return } -func (p *LegalAndValidParser) SplitE164(e164 string) (nationalNumber string, countryCallingCode string, err error) { +func (p *legalAndValidParser) SplitE164(e164 string) (nationalNumber string, countryCallingCode string, err error) { err = p.CheckE164(e164) if err != nil { return @@ -51,7 +51,7 @@ func (p *LegalAndValidParser) SplitE164(e164 string) (nationalNumber string, cou return } -func (p *LegalAndValidParser) CheckE164(phone string) error { +func (p *legalAndValidParser) CheckE164(phone string) error { formatted, err := p.ParseInputPhoneNumber(phone) if err != nil { return err @@ -62,7 +62,7 @@ func (p *LegalAndValidParser) CheckE164(phone string) error { return nil } -func (p *LegalAndValidParser) IsNorthAmericaNumber(e164 string) (bool, error) { +func (p *legalAndValidParser) IsNorthAmericaNumber(e164 string) (bool, error) { _, callingCode, err := p.SplitE164(e164) if err != nil { return false, err diff --git a/pkg/util/phone/legal_and_valid_parser_test.go b/pkg/util/phone/legal_and_valid_parser_test.go index f08aa1df4e..3014ec285a 100644 --- a/pkg/util/phone/legal_and_valid_parser_test.go +++ b/pkg/util/phone/legal_and_valid_parser_test.go @@ -7,7 +7,7 @@ import ( ) func TestLegalAndValidParser(t *testing.T) { - parser := &LegalAndValidParser{} + parser := &legalAndValidParser{} Convey("Phone", t, func() { Convey("checkE164", func() { good := "+85223456789" diff --git a/pkg/util/phone/legal_parser.go b/pkg/util/phone/legal_parser.go index 85f34384b3..b9078623c3 100644 --- a/pkg/util/phone/legal_parser.go +++ b/pkg/util/phone/legal_parser.go @@ -8,9 +8,9 @@ import ( ) // LegalParser parses a legal phone number. A legal phone number is a phone number that passes phonenumbers.Parse(). -type LegalParser struct{} +type legalParser struct{} -func (p *LegalParser) ParseInputPhoneNumber(phone string) (e164 string, err error) { +func (p *legalParser) ParseInputPhoneNumber(phone string) (e164 string, err error) { isNumericString, _ := regexp.Match(`^\+[0-9\ \-]*$`, []byte(phone)) if !isNumericString { err = ErrNotInE164Format @@ -24,14 +24,14 @@ func (p *LegalParser) ParseInputPhoneNumber(phone string) (e164 string, err erro return } -func (p *LegalParser) ParseCountryCallingCodeAndNationalNumber(nationalNumber string, countryCallingCode string) (e164 string, err error) { +func (p *legalParser) ParseCountryCallingCodeAndNationalNumber(nationalNumber string, countryCallingCode string) (e164 string, err error) { rawInput := combineCallingCodeWithNumber(nationalNumber, countryCallingCode) e164, err = p.ParseInputPhoneNumber(rawInput) return } -func (p *LegalParser) SplitE164(e164 string) (nationalNumber string, countryCallingCode string, err error) { +func (p *legalParser) SplitE164(e164 string) (nationalNumber string, countryCallingCode string, err error) { err = p.CheckE164(e164) if err != nil { return @@ -46,7 +46,7 @@ func (p *LegalParser) SplitE164(e164 string) (nationalNumber string, countryCall return } -func (p *LegalParser) CheckE164(phone string) error { +func (p *legalParser) CheckE164(phone string) error { formatted, err := p.ParseInputPhoneNumber(phone) if err != nil { return err @@ -57,7 +57,7 @@ func (p *LegalParser) CheckE164(phone string) error { return nil } -func (p *LegalParser) IsNorthAmericaNumber(e164 string) (bool, error) { +func (p *legalParser) IsNorthAmericaNumber(e164 string) (bool, error) { _, callingCode, err := p.SplitE164(e164) if err != nil { return false, err diff --git a/pkg/util/phone/legal_parser_test.go b/pkg/util/phone/legal_parser_test.go index cbfbb7bb8e..0cb196930e 100644 --- a/pkg/util/phone/legal_parser_test.go +++ b/pkg/util/phone/legal_parser_test.go @@ -7,7 +7,7 @@ import ( ) func TestLegalParser(t *testing.T) { - parser := &LegalParser{} + parser := &legalParser{} Convey("Phone", t, func() { Convey("checkE164", func() { good := "+85223456789" diff --git a/pkg/util/phone/parser.go b/pkg/util/phone/parser.go new file mode 100644 index 0000000000..614990723c --- /dev/null +++ b/pkg/util/phone/parser.go @@ -0,0 +1,12 @@ +package phone + +type Parser interface { + ParseInputPhoneNumber(phone string) (e164 string, err error) + ParseCountryCallingCodeAndNationalNumber(nationalNumber string, countryCallingCode string) (e164 string, err error) + SplitE164(e164 string) (nationalNumber string, countryCallingCode string, err error) + CheckE164(phone string) error + IsNorthAmericaNumber(e164 string) (bool, error) +} + +var LegalParser Parser = &legalParser{} +var LegalAndValidParser Parser = &legalAndValidParser{} diff --git a/pkg/util/validation/formats.go b/pkg/util/validation/formats.go index fe4fe78afb..a8a218a155 100644 --- a/pkg/util/validation/formats.go +++ b/pkg/util/validation/formats.go @@ -63,8 +63,7 @@ func (f FormatPhone) CheckFormat(value interface{}) error { if !ok { return nil } - phoneNumberParser := &phone.LegalAndValidParser{} - return phoneNumberParser.CheckE164(str) + return phone.LegalAndValidParser.CheckE164(str) } // FormatEmail checks if input is an email address.