Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add phone normalizer #4062

Merged
merged 10 commits into from
Mar 28, 2024
2 changes: 1 addition & 1 deletion pkg/admin/graphql/user_mutation.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ var _ = registerMutationField(
gqlCtx := GQLContext(p.Context)

var channel apimodel.AuthenticatorOOBChannel
if err := phone.EnsureE164(target); err == nil {
if err := phone.LegalAndValidParser.CheckE164(target); err == nil {
channel = apimodel.AuthenticatorOOBChannelSMS
} else {
channel = apimodel.AuthenticatorOOBChannelEmail
Expand Down
2 changes: 1 addition & 1 deletion pkg/auth/webapp/authflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func GetMostAppropriateIdentification(f *authflow.FlowResponse, loginID string,
// Else, guess the type

lookLikeAPhoneNumber := func(loginID string) bool {
err := phone.EnsureE164(loginID)
err := phone.LegalAndValidParser.CheckE164(loginID)
if err == nil {
return true
}
Expand Down
19 changes: 19 additions & 0 deletions pkg/lib/authn/identity/loginid/normalizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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{}
Expand Down Expand Up @@ -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.LegalParser.ParseInputPhoneNumber(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) {
Expand Down
22 changes: 22 additions & 0 deletions pkg/lib/authn/identity/loginid/normalizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
})
}
8 changes: 5 additions & 3 deletions pkg/lib/authn/stdattrs/normalizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ 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)
phoneNumber, err := phone.LegalParser.ParseInputPhoneNumber(phoneNumber)
if err != nil {
return err
}

t[PhoneNumber] = phoneNumber
} else {
delete(t, PhoneNumber)
}
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/lib/elasticsearch/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func RawToSource(raw *model.ElasticsearchUserRaw) *model.ElasticsearchUserSource
var phoneNumberCountryCode []string
var phoneNumberNationalNumber []string
for _, phoneNumber := range raw.PhoneNumber {
nationalNumber, callingCode, err := phone.ParseE164ToCallingCodeAndNumber(phoneNumber)
nationalNumber, callingCode, err := phone.LegalAndValidParser.SplitE164(phoneNumber)
if err == nil {
phoneNumberCountryCode = append(phoneNumberCountryCode, callingCode)
phoneNumberNationalNumber = append(phoneNumberNationalNumber, nationalNumber)
Expand Down
5 changes: 3 additions & 2 deletions pkg/lib/usage/count_collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,8 @@ func (c *CountCollector) querySMSCount(appID string, rangeFrom *time.Time, range
}

e164 := payload.Recipient
isNorthAmericaNumber, err := phoneutil.IsNorthAmericaNumber(e164)

isNorthAmericaNumber, err := phoneutil.LegalAndValidParser.IsNorthAmericaNumber(e164)
if err != nil {
return nil, fmt.Errorf("usage: failed to parse sms recipient %w", err)
}
Expand Down Expand Up @@ -345,7 +346,7 @@ func (c *CountCollector) queryWhatsappCount(appID string, rangeFrom *time.Time,

e164 := payload.Recipient

isNorthAmericaNumber, err := phoneutil.IsNorthAmericaNumber(e164)
isNorthAmericaNumber, err := phoneutil.LegalAndValidParser.IsNorthAmericaNumber(e164)
if err != nil {
return nil, fmt.Errorf("usage: failed to parse whatsapp recipient %w", err)
}
Expand Down
72 changes: 72 additions & 0 deletions pkg/util/phone/legal_and_valid_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package phone

import (
"regexp"
"strconv"

"github.com/nyaruka/phonenumbers"
)

// 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) {
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
}
137 changes: 137 additions & 0 deletions pkg/util/phone/legal_and_valid_parser_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
})
}
Loading
Loading