Skip to content

Commit 4941483

Browse files
authored
feat(core): enhanced user lookup by phone number (#7382)
* feat(core): enhenced user lookup by phone number enhenced user lookup by phone with phone number normalization. * chore: update content fix typo ` * fix: clean up typos clean up typos * fix(test): remove test case skip statement remove test case skip statement and update changeset subject * chore: fix typo fix typo
1 parent ca37052 commit 4941483

File tree

7 files changed

+411
-11
lines changed

7 files changed

+411
-11
lines changed

.changeset/nice-houses-sneeze.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@logto/core": minor
3+
---
4+
5+
refactor: enhanced user lookup by phone with phone number normalization
6+
7+
In some countries, local phone numbers are often entered with a leading '0'. However, in the context of the international format this leading '0' should be stripped. E.g., +61 (0)2 1234 5678 should be normalized to +61 2 1234 5678.
8+
9+
In the previous implementation, Logto did not normalize the user's phone number during the user sign-up process. Both 61021345678 and 61212345678 were considered as valid phone numbers, and we do not normalize them before storing them in the database. This could lead to confusion when users try to sign-in with their phone numbers, as they may not remember the exact format they used during sign-up. Users may also end up with different accounts for the same phone number, depending on how they entered it during sign-up.
10+
11+
To address this issue, especially for legacy users, we have added a new enhenced user lookup by phone with either format (with or without leading '0') to the user sign-in process. This means that users can now sign-in with either format of their phone number, and Logto will try to match it with the one stored in the database, even if they might have different formats. This will help to reduce confusion and improve the user experience when logging in with phone numbers.
12+
13+
For example:
14+
15+
- If a user signs up with the phone number +61 2 1234 5678, they can now sign-in with either +61 2 1234 5678 or +61 02 1234 5678.
16+
- The same applies to the phone number +61 02 1234 5678, which can be used to sign-in with either +61 2 1234 5678 or +61 02 1234 5678.
17+
18+
For users who might have created two different accounts with the same phone number but different formats. The lookup process will always return the one with an exact match. This means that if a user has two accounts with the same phone number but different formats, they will still be able to sign-in with either format, but they will only be able to access the account that matches the format they used during sign-up.
19+
20+
For example:
21+
22+
- If a user has two accounts with the phone numbers +61 2 1234 5678 and +61 02 1234 5678. They will need to sign-in to each account using the exact format they used during sign-up.
23+
24+
related github issue [#7371](https://github.com/logto-io/logto/issues/7371).

packages/core/src/libraries/social.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const getUserInfoFromInteractionResult = async (
3636
};
3737

3838
export const createSocialLibrary = (queries: Queries, connectorLibrary: ConnectorLibrary) => {
39-
const { findUserByEmail, findUserByPhone } = queries.users;
39+
const { findUserByEmail, findUserByNormalizedPhone } = queries.users;
4040
const { getLogtoConnectorById } = connectorLibrary;
4141

4242
const getConnector = async (connectorId: string): Promise<LogtoConnector> => {
@@ -85,7 +85,7 @@ export const createSocialLibrary = (queries: Queries, connectorLibrary: Connecto
8585
info: SocialUserInfo
8686
): Promise<Nullable<[{ type: 'email' | 'phone'; value: string }, User]>> => {
8787
if (info.phone) {
88-
const user = await findUserByPhone(info.phone);
88+
const user = await findUserByNormalizedPhone(info.phone);
8989

9090
if (user) {
9191
return [{ type: 'phone', value: info.phone }, user];

packages/core/src/queries/user.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { User, CreateUser } from '@logto/schemas';
22
import { Users } from '@logto/schemas';
3-
import { conditionalArray, pick } from '@silverhand/essentials';
3+
import { PhoneNumberParser } from '@logto/shared';
4+
import { conditionalArray, type Nullable, pick } from '@silverhand/essentials';
45
import type { CommonQueryMethods } from '@silverhand/slonik';
56
import { sql } from '@silverhand/slonik';
67

@@ -85,6 +86,71 @@ export const createUserQueries = (pool: CommonQueryMethods) => {
8586
where ${fields.primaryPhone}=${phone}
8687
`);
8788

89+
/**
90+
* Find user by phone with normalized match.
91+
*
92+
* @remarks
93+
* In some countries, local phone numbers are often entered with a leading '0'.
94+
* However, in the international format that includes the country code, this leading '0' should be removed.
95+
* The previous implementation did not handle this correctly, causing the combination of country code + 0 + local number
96+
* to be treated as different from country code + local number in the Logto system.
97+
* Both formats should be considered the same phone number.
98+
*
99+
* To address this, this function will:
100+
*
101+
* 1. Normalize the input phone number by separating it into a standard country code and a local number without the leading '0'.
102+
* 2. Query the user by the phone number both with and without the leading '0'.
103+
* If one match is found, return that account. If multiple matches exist (e.g., a user registered two accounts with the same phone number, one with a leading '0' and one without), return the exact match.
104+
* 3. If the phone number cannot be normalized, attempt to find it with an exact match.
105+
*
106+
* @example
107+
* - DB: 61 0412 345 678
108+
* - input: 61 412 345 678
109+
* - return : user with 61 0412 345 678
110+
*
111+
* @example
112+
* - DB: 61 412 345 678
113+
* - input: 61 0412 345 678
114+
* - return : user with 61 412 345 678
115+
*
116+
* @example
117+
* - DB: 61 0412 345 678, 61 412 345 678
118+
* - input: 61 0412 345 678
119+
* - return : user with 61 0412 345 678
120+
*/
121+
const findUserByNormalizedPhone = async (phone: string): Promise<Nullable<User>> => {
122+
const phoneNumberParser = new PhoneNumberParser(phone);
123+
124+
const { internationalNumber, internationalNumberWithLeadingZero, isValid } = phoneNumberParser;
125+
126+
// If the phone number is not a valid international phone number, return the user with the exact match.
127+
if (!isValid || !internationalNumber || !internationalNumberWithLeadingZero) {
128+
return findUserByPhone(phone);
129+
}
130+
131+
const users = await pool.any<User>(sql`
132+
select ${sql.join(Object.values(fields), sql`,`)}
133+
from ${table}
134+
where ${fields.primaryPhone}=${internationalNumber}
135+
or ${fields.primaryPhone}=${internationalNumberWithLeadingZero}
136+
order by ${fields.createdAt} desc
137+
`);
138+
139+
if (users.length === 0) {
140+
return null;
141+
}
142+
143+
// If only one user is found, return that user.
144+
if (users.length === 1) {
145+
return users[0] ?? null;
146+
}
147+
148+
// Incase user has created two different accounts with the same phone number, one with leading '0' and one without.
149+
// If more than one user is found, return the user with the exact match.
150+
// Otherwise, return the first found user, which should be the be the latest one.
151+
return users.find((user) => user.primaryPhone === phone) ?? users[0] ?? null;
152+
};
153+
88154
const findUserById = async (id: string): Promise<User> =>
89155
pool.one<User>(sql`
90156
select ${sql.join(Object.values(fields), sql`,`)}
@@ -128,6 +194,9 @@ export const createUserQueries = (pool: CommonQueryMethods) => {
128194
${conditionalSql(excludeUserId, (id) => sql`and ${fields.id}<>${id}`)}
129195
`);
130196

197+
/**
198+
* Find user by phone with exact match.
199+
*/
131200
const hasUserWithPhone = async (phone: string, excludeUserId?: string) =>
132201
pool.exists(sql`
133202
select ${fields.primaryPhone}
@@ -265,6 +334,7 @@ export const createUserQueries = (pool: CommonQueryMethods) => {
265334
findUserByUsername,
266335
findUserByEmail,
267336
findUserByPhone,
337+
findUserByNormalizedPhone,
268338
findUserById,
269339
findUserByIdentity,
270340
hasUser,

packages/core/src/routes/experience/classes/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const findUserByIdentifier = async (
2424
return userQuery.findUserByEmail(value);
2525
}
2626
case SignInIdentifier.Phone: {
27-
return userQuery.findUserByPhone(value);
27+
return userQuery.findUserByNormalizedPhone(value);
2828
}
2929
case AdditionalIdentifier.UserId: {
3030
return userQuery.findUserById(value);

packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import {
1717
enableAllPasswordSignInMethods,
1818
enableAllVerificationCodeSignInMethods,
1919
} from '#src/helpers/sign-in-experience.js';
20-
import { generateNewUser } from '#src/helpers/user.js';
21-
import { devFeatureTest, generateEmail } from '#src/utils.js';
20+
import { generateNewUser, UserApiTest } from '#src/helpers/user.js';
21+
import { devFeatureTest, generateEmail, generatePassword } from '#src/utils.js';
2222

2323
const identifiersTypeToUserProfile = Object.freeze({
2424
username: 'username',
@@ -148,3 +148,95 @@ describe('sign-in with password verification happy path', () => {
148148
await deleteUser(user.id);
149149
});
150150
});
151+
152+
describe('phone number sanitisation sign-in test +61 412 345 678', () => {
153+
const testPhoneNumber = '61412345678';
154+
const testPhoneNumberWithLeadingZero = '610412345678';
155+
const password = generatePassword();
156+
const userApi = new UserApiTest();
157+
158+
beforeAll(async () => {
159+
await enableAllPasswordSignInMethods();
160+
await updateSignInExperience({
161+
sentinelPolicy: {
162+
maxAttempts: 100,
163+
},
164+
});
165+
});
166+
167+
afterEach(async () => {
168+
await userApi.cleanUp();
169+
});
170+
171+
type TestCase = {
172+
registerPhone: string;
173+
signInPhone: string;
174+
};
175+
const testCases: TestCase[] = [
176+
{
177+
registerPhone: testPhoneNumber,
178+
signInPhone: testPhoneNumber,
179+
},
180+
{
181+
registerPhone: testPhoneNumberWithLeadingZero,
182+
signInPhone: testPhoneNumberWithLeadingZero,
183+
},
184+
{
185+
registerPhone: testPhoneNumber,
186+
signInPhone: testPhoneNumberWithLeadingZero,
187+
},
188+
{
189+
registerPhone: testPhoneNumberWithLeadingZero,
190+
signInPhone: testPhoneNumber,
191+
},
192+
];
193+
194+
it.each(testCases)(
195+
'should register with $registerPhone and sign-in with $signInPhone successfully',
196+
async ({ registerPhone, signInPhone }) => {
197+
const user = await userApi.create({
198+
primaryPhone: registerPhone,
199+
password,
200+
});
201+
202+
await signInWithPassword({
203+
identifier: {
204+
type: SignInIdentifier.Phone,
205+
value: signInPhone,
206+
},
207+
password,
208+
});
209+
}
210+
);
211+
212+
it('should sign-in with extact phone number if multiple account is found', async () => {
213+
const passwordA = generatePassword();
214+
const passwordB = generatePassword();
215+
216+
await userApi.create({
217+
primaryPhone: testPhoneNumber,
218+
password: passwordA,
219+
});
220+
221+
await userApi.create({
222+
primaryPhone: testPhoneNumberWithLeadingZero,
223+
password: passwordB,
224+
});
225+
226+
await signInWithPassword({
227+
identifier: {
228+
type: SignInIdentifier.Phone,
229+
value: testPhoneNumber,
230+
},
231+
password: passwordA,
232+
});
233+
234+
await signInWithPassword({
235+
identifier: {
236+
type: SignInIdentifier.Phone,
237+
value: testPhoneNumberWithLeadingZero,
238+
},
239+
password: passwordB,
240+
});
241+
});
242+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { parsePhoneNumber, PhoneNumberParser } from './phone.js';
4+
5+
describe('parsePhoneNumber', () => {
6+
it('parsePhoneNumber should return if the phone number is valid', () => {
7+
const phoneNumber = '12025550123';
8+
expect(parsePhoneNumber(phoneNumber, true)).toEqual('12025550123');
9+
});
10+
11+
it('parsePhoneNumber should strip the leading +', () => {
12+
const phoneNumber = '+12025550123';
13+
expect(parsePhoneNumber(phoneNumber)).toEqual('12025550123');
14+
});
15+
16+
it('parsePhoneNumber should strip the leading 0', () => {
17+
const phoneNumber = '610412345678';
18+
expect(parsePhoneNumber(phoneNumber)).toEqual('61412345678');
19+
});
20+
21+
it('parsePhoneNumber should strip non-digit characters', () => {
22+
const phoneNumber = '+61 (0) 412 345 678';
23+
expect(parsePhoneNumber(phoneNumber)).toEqual('61412345678');
24+
});
25+
26+
it('should return the original phone number if it is invalid', () => {
27+
const phoneNumber = '0123';
28+
expect(parsePhoneNumber(phoneNumber)).toEqual(phoneNumber);
29+
});
30+
31+
it('should throw an error if the phone number is invalid and throwError is true', () => {
32+
const phoneNumber = '0123';
33+
expect(() => parsePhoneNumber(phoneNumber, true)).toThrowError();
34+
});
35+
});
36+
37+
describe('PhoneNumberParser', () => {
38+
type TestCase = {
39+
phone: string;
40+
isValid: boolean;
41+
countryCode?: string;
42+
nationalNumber?: string;
43+
internationalNumber?: string;
44+
internationalNumberWithLeadingZero?: string;
45+
};
46+
const testCases: TestCase[] = [
47+
{
48+
phone: '12025550123',
49+
isValid: true,
50+
countryCode: '1',
51+
nationalNumber: '2025550123',
52+
internationalNumber: '12025550123',
53+
internationalNumberWithLeadingZero: '102025550123',
54+
},
55+
{
56+
phone: '+61 (0) 412 345 678',
57+
isValid: true,
58+
countryCode: '61',
59+
nationalNumber: '412345678',
60+
internationalNumber: '61412345678',
61+
internationalNumberWithLeadingZero: '610412345678',
62+
},
63+
{
64+
phone: '61 412 345 678',
65+
isValid: true,
66+
countryCode: '61',
67+
nationalNumber: '412345678',
68+
internationalNumber: '61412345678',
69+
internationalNumberWithLeadingZero: '610412345678',
70+
},
71+
{
72+
phone: '456',
73+
isValid: false,
74+
countryCode: undefined,
75+
nationalNumber: undefined,
76+
internationalNumber: undefined,
77+
internationalNumberWithLeadingZero: undefined,
78+
},
79+
];
80+
81+
it.each(testCases)(
82+
'parsePhoneNumber should return $phone if the phone number is valid',
83+
({
84+
phone,
85+
isValid,
86+
countryCode,
87+
nationalNumber,
88+
internationalNumber,
89+
internationalNumberWithLeadingZero,
90+
}) => {
91+
const parser = new PhoneNumberParser(phone);
92+
expect(parser.raw).toEqual(phone);
93+
expect(parser.isValid).toEqual(isValid);
94+
expect(parser.countryCode).toEqual(countryCode);
95+
expect(parser.nationalNumber).toEqual(nationalNumber);
96+
expect(parser.internationalNumber).toEqual(internationalNumber);
97+
expect(parser.internationalNumberWithLeadingZero).toEqual(internationalNumberWithLeadingZero);
98+
}
99+
);
100+
});

0 commit comments

Comments
 (0)