0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat: add phone number validation to user APIs

This commit is contained in:
Darcy Ye 2024-05-16 17:07:05 +08:00
parent 136320584f
commit 6cc51d098b
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
33 changed files with 273 additions and 141 deletions

View file

@ -86,7 +86,7 @@
"jest-transformer-svg": "^2.0.0", "jest-transformer-svg": "^2.0.0",
"just-kebab-case": "^4.2.0", "just-kebab-case": "^4.2.0",
"ky": "^1.2.3", "ky": "^1.2.3",
"libphonenumber-js": "^1.10.51", "libphonenumber-js": "^1.11.1",
"lint-staged": "^15.0.0", "lint-staged": "^15.0.0",
"nanoid": "^5.0.1", "nanoid": "^5.0.1",
"overlayscrollbars": "^2.0.2", "overlayscrollbars": "^2.0.2",

View file

@ -2,7 +2,7 @@ import { emailRegEx, usernameRegEx } from '@logto/core-kit';
import type { User } from '@logto/schemas'; import type { User } from '@logto/schemas';
import { parsePhoneNumber } from '@logto/shared/universal'; import { parsePhoneNumber } from '@logto/shared/universal';
import { conditionalString, trySafe } from '@silverhand/essentials'; import { conditionalString, trySafe } from '@silverhand/essentials';
import { parsePhoneNumberWithError } from 'libphonenumber-js'; import { parsePhoneNumberWithError } from 'libphonenumber-js/mobile';
import { useForm, useController } from 'react-hook-form'; import { useForm, useController } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View file

@ -186,11 +186,15 @@ describe('verifyUserPassword()', () => {
}; };
it('migrates password to Argon2', async () => { it('migrates password to Argon2', async () => {
await verifyUserPassword(user, 'password'); await verifyUserPassword(user, 'password');
expect(updateUserById).toHaveBeenCalledWith(user.id, { expect(updateUserById).toHaveBeenCalledWith(
user.id,
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
passwordEncrypted: expect.stringContaining('argon2'), passwordEncrypted: expect.stringContaining('argon2'),
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i, passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i,
}); },
undefined
);
}); });
}); });
}); });
@ -220,6 +224,7 @@ describe('addUserMfaVerification()', () => {
beforeAll(() => { beforeAll(() => {
jest.useFakeTimers(); jest.useFakeTimers();
jest.setSystemTime(new Date(createdAt)); jest.setSystemTime(new Date(createdAt));
jest.clearAllMocks();
}); });
afterAll(() => { afterAll(() => {
@ -227,10 +232,19 @@ describe('addUserMfaVerification()', () => {
}); });
it('update user with new mfa verification', async () => { it('update user with new mfa verification', async () => {
await addUserMfaVerification(mockUser.id, { type: MfaFactor.TOTP, secret: 'secret' }); await addUserMfaVerification(mockUser.id, {
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, { type: MfaFactor.TOTP,
secret: 'secret',
});
expect(updateUserById).toHaveBeenCalledWith(
mockUser.id,
{
mfaVerifications: [
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
mfaVerifications: [{ type: MfaFactor.TOTP, key: 'secret', id: expect.anything(), createdAt }], { type: MfaFactor.TOTP, key: 'secret', id: expect.anything(), createdAt },
}); ],
},
undefined
);
}); });
}); });

View file

@ -1,7 +1,8 @@
import type { BindMfa, CreateUser, MfaVerification, Scope, User } from '@logto/schemas'; import type { BindMfa, CreateUser, MfaVerification, Scope, User } from '@logto/schemas';
import { MfaFactor, RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas'; import { MfaFactor, RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
import { generateStandardId, generateStandardShortId } from '@logto/shared'; import { generateStandardShortId, generateStandardId } from '@logto/shared';
import { deduplicateByKey, type Nullable } from '@silverhand/essentials'; import type { Nullable } from '@silverhand/essentials';
import { deduplicateByKey, conditional } from '@silverhand/essentials';
import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm'; import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm';
import pRetry from 'p-retry'; import pRetry from 'p-retry';
@ -14,6 +15,7 @@ import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { encryptPassword } from '#src/utils/password.js'; import { encryptPassword } from '#src/utils/password.js';
import type { OmitAutoSetFields } from '#src/utils/sql.js'; import type { OmitAutoSetFields } from '#src/utils/sql.js';
import { getValidPhoneNumber } from '#src/utils/user.js';
export const encryptUserPassword = async ( export const encryptUserPassword = async (
password: string password: string
@ -90,7 +92,7 @@ export const createUserLibrary = (queries: Queries) => {
hasUserWithId, hasUserWithId,
hasUserWithPhone, hasUserWithPhone,
findUsersByIds, findUsersByIds,
updateUserById, updateUserById: updateUserByIdQuery,
findUserById, findUserById,
}, },
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId }, usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
@ -115,6 +117,24 @@ export const createUserLibrary = (queries: Queries) => {
{ retries, factor: 0 } // No need for exponential backoff { retries, factor: 0 } // No need for exponential backoff
); );
const updateUserById = async (
id: string,
set: Partial<OmitAutoSetFields<CreateUser>>,
jsonbMode?: 'replace' | 'merge'
) => {
const validPhoneNumber = conditional(
'primaryPhone' in set &&
typeof set.primaryPhone === 'string' &&
getValidPhoneNumber(set.primaryPhone)
);
return updateUserByIdQuery(
id,
{ ...set, ...conditional(validPhoneNumber && { primaryPhone: validPhoneNumber }) },
jsonbMode
);
};
const insertUser = async ( const insertUser = async (
data: OmitAutoSetFields<CreateUser>, data: OmitAutoSetFields<CreateUser>,
additionalRoleNames: string[] additionalRoleNames: string[]
@ -127,12 +147,21 @@ export const createUserLibrary = (queries: Queries) => {
assertThat(parameterRoles.length === roleNames.length, 'role.default_role_missing'); assertThat(parameterRoles.length === roleNames.length, 'role.default_role_missing');
const validPhoneNumber = conditional(
'primaryPhone' in data &&
typeof data.primaryPhone === 'string' &&
getValidPhoneNumber(data.primaryPhone)
);
return pool.transaction(async (connection) => { return pool.transaction(async (connection) => {
const insertUserQuery = buildInsertIntoWithPool(connection)(Users, { const insertUserQuery = buildInsertIntoWithPool(connection)(Users, {
returning: true, returning: true,
}); });
const user = await insertUserQuery(data); const user = await insertUserQuery({
...data,
...conditional(validPhoneNumber && { primaryPhone: validPhoneNumber }),
});
const roles = deduplicateByKey([...parameterRoles, ...defaultRoles], 'id'); const roles = deduplicateByKey([...parameterRoles, ...defaultRoles], 'id');
if (roles.length > 0) { if (roles.length > 0) {
@ -336,5 +365,6 @@ export const createUserLibrary = (queries: Queries) => {
verifyUserPassword, verifyUserPassword,
signOutUser, signOutUser,
findUserSsoIdentities, findUserSsoIdentities,
updateUserById,
}; };
}; };

View file

@ -22,9 +22,12 @@ export default function socialRoutes<T extends AuthedMeRouter>(
) { ) {
const { const {
queries: { queries: {
users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity }, users: { findUserById, deleteUserIdentity, hasUserWithIdentity },
signInExperiences: { findDefaultSignInExperience }, signInExperiences: { findDefaultSignInExperience },
}, },
libraries: {
users: { updateUserById },
},
connectors: { getLogtoConnectors, getLogtoConnectorById }, connectors: { getLogtoConnectors, getLogtoConnectorById },
} = tenant; } = tenant;

View file

@ -17,10 +17,10 @@ export default function userRoutes<T extends AuthedMeRouter>(
) { ) {
const { const {
queries: { queries: {
users: { findUserById, updateUserById }, users: { findUserById },
}, },
libraries: { libraries: {
users: { checkIdentifierCollision, verifyUserPassword }, users: { checkIdentifierCollision, verifyUserPassword, updateUserById },
verificationStatuses: { createVerificationStatus, checkVerificationStatus }, verificationStatuses: { createVerificationStatus, checkVerificationStatus },
}, },
} = tenant; } = tenant;

View file

@ -35,12 +35,6 @@ const mockedQueries = {
hasUser: jest.fn(async () => mockHasUser()), hasUser: jest.fn(async () => mockHasUser()),
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()), hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()), hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
deleteUserById: jest.fn(), deleteUserById: jest.fn(),
deleteUserIdentity: jest.fn(), deleteUserIdentity: jest.fn(),
}, },
@ -67,8 +61,7 @@ const mockHasUser = jest.fn(async () => false);
const mockHasUserWithEmail = jest.fn(async () => false); const mockHasUserWithEmail = jest.fn(async () => false);
const mockHasUserWithPhone = jest.fn(async () => false); const mockHasUserWithPhone = jest.fn(async () => false);
const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } = const { hasUser, findUserById, deleteUserIdentity, deleteUserById } = mockedQueries.users;
mockedQueries.users;
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({ const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
encryptUserPassword: jest.fn(() => ({ encryptUserPassword: jest.fn(() => ({
@ -92,8 +85,16 @@ const usersLibraries = {
), ),
verifyUserPassword, verifyUserPassword,
signOutUser, signOutUser,
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
} satisfies Partial<Libraries['users']>; } satisfies Partial<Libraries['users']>;
const { updateUserById } = usersLibraries;
const adminUserRoutes = await pickDefault(import('./basics.js')); const adminUserRoutes = await pickDefault(import('./basics.js'));
describe('adminUserRoutes', () => { describe('adminUserRoutes', () => {

View file

@ -23,14 +23,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
) { ) {
const [router, { queries, libraries }] = args; const [router, { queries, libraries }] = args;
const { const {
users: { users: { deleteUserById, findUserById, hasUser, hasUserWithEmail, hasUserWithPhone },
deleteUserById,
findUserById,
hasUser,
updateUserById,
hasUserWithEmail,
hasUserWithPhone,
},
} = queries; } = queries;
const { const {
users: { users: {
@ -40,6 +33,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
verifyUserPassword, verifyUserPassword,
signOutUser, signOutUser,
findUserSsoIdentities, findUserSsoIdentities,
updateUserById,
}, },
} = libraries; } = libraries;

View file

@ -8,7 +8,6 @@ import {
mockUserTotpMfaVerification, mockUserTotpMfaVerification,
mockUserWithMfaVerifications, mockUserWithMfaVerifications,
} from '#src/__mocks__/index.js'; } from '#src/__mocks__/index.js';
import { type InsertUserResult } from '#src/libraries/user.js';
import type Libraries from '#src/tenants/Libraries.js'; import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js'; import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
@ -23,12 +22,6 @@ const mockedQueries = {
hasUser: jest.fn(async () => mockHasUser()), hasUser: jest.fn(async () => mockHasUser()),
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()), hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()), hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
deleteUserById: jest.fn(), deleteUserById: jest.fn(),
deleteUserIdentity: jest.fn(), deleteUserIdentity: jest.fn(),
}, },
@ -38,7 +31,7 @@ const mockHasUser = jest.fn(async () => false);
const mockHasUserWithEmail = jest.fn(async () => false); const mockHasUserWithEmail = jest.fn(async () => false);
const mockHasUserWithPhone = jest.fn(async () => false); const mockHasUserWithPhone = jest.fn(async () => false);
const { findUserById, updateUserById } = mockedQueries.users; const { findUserById } = mockedQueries.users;
await mockEsmWithActual('../interaction/utils/totp-validation.js', () => ({ await mockEsmWithActual('../interaction/utils/totp-validation.js', () => ({
generateTotpSecret: jest.fn().mockReturnValue('totp_secret'), generateTotpSecret: jest.fn().mockReturnValue('totp_secret'),
@ -47,25 +40,31 @@ await mockEsmWithActual('../interaction/utils/backup-code-validation.js', () =>
generateBackupCodes: jest.fn().mockReturnValue(['code']), generateBackupCodes: jest.fn().mockReturnValue(['code']),
})); }));
const usersLibraries = { const mockLibraries = {
users: {
generateUserId: jest.fn(async () => 'fooId'), generateUserId: jest.fn(async () => 'fooId'),
insertUser: jest.fn( insertUser: jest.fn(
async (user: CreateUser): Promise<InsertUserResult> => [ async (user: CreateUser): Promise<User> => ({
{
...mockUser, ...mockUser,
...removeUndefinedKeys(user), // No undefined values will be returned from database ...removeUndefinedKeys(user), // No undefined values will be returned from database
}, })
{ organizationIds: [] },
]
), ),
} satisfies Partial<Libraries['users']>; updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
addUserMfaVerification: jest.fn(),
},
} satisfies Partial2<Libraries>;
const { updateUserById } = mockLibraries.users;
const adminUserRoutes = await pickDefault(import('./mfa-verifications.js')); const adminUserRoutes = await pickDefault(import('./mfa-verifications.js'));
describe('adminUserRoutes', () => { describe('adminUserRoutes', () => {
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, { const tenantContext = new MockTenant(undefined, mockedQueries, undefined, mockLibraries);
users: usersLibraries,
});
const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext }); const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext });
afterEach(() => { afterEach(() => {

View file

@ -21,12 +21,12 @@ export default function adminUserMfaVerificationsRoutes<T extends ManagementApiR
{ {
queries, queries,
libraries: { libraries: {
users: { addUserMfaVerification }, users: { addUserMfaVerification, updateUserById },
}, },
}, },
] = args; ] = args;
const { const {
users: { findUserById, updateUserById }, users: { findUserById },
} = queries; } = queries;
router.get( router.get(

View file

@ -34,12 +34,6 @@ const mockedQueries = {
} }
return mockUser; return mockUser;
}), }),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
hasUserWithIdentity: mockHasUserWithIdentity, hasUserWithIdentity: mockHasUserWithIdentity,
deleteUserById: jest.fn(), deleteUserById: jest.fn(),
deleteUserIdentity: jest.fn(), deleteUserIdentity: jest.fn(),
@ -57,6 +51,12 @@ const usersLibraries = {
{ organizationIds: [] }, { organizationIds: [] },
] ]
), ),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
} satisfies Partial<Libraries['users']>; } satisfies Partial<Libraries['users']>;
const mockGetLogtoConnectors = jest.fn(async () => mockLogtoConnectorList); const mockGetLogtoConnectors = jest.fn(async () => mockLogtoConnectorList);
@ -78,7 +78,8 @@ const mockedConnectors = {
}, },
}; };
const { findUserById, updateUserById, deleteUserIdentity } = mockedQueries.users; const { findUserById, deleteUserIdentity } = mockedQueries.users;
const { updateUserById } = usersLibraries;
const adminUserSocialRoutes = await pickDefault(import('./social.js')); const adminUserSocialRoutes = await pickDefault(import('./social.js'));

View file

@ -20,7 +20,10 @@ export default function adminUserSocialRoutes<T extends ManagementApiRouter>(
) { ) {
const { const {
queries: { queries: {
users: { findUserById, updateUserById, hasUserWithIdentity, deleteUserIdentity }, users: { findUserById, hasUserWithIdentity, deleteUserIdentity },
},
libraries: {
users: { updateUserById },
}, },
connectors: { getLogtoConnectorById }, connectors: { getLogtoConnectorById },
} = tenant; } = tenant;

View file

@ -44,19 +44,19 @@ const userQueries = {
identities: { google: { userId: 'googleId', details: {} } }, identities: { google: { userId: 'googleId', details: {} } },
mfaVerifications: [], mfaVerifications: [],
}), }),
updateUserById: jest.fn(),
hasActiveUsers: jest.fn().mockResolvedValue(true), hasActiveUsers: jest.fn().mockResolvedValue(true),
hasUserWithEmail: jest.fn().mockResolvedValue(false), hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false), hasUserWithPhone: jest.fn().mockResolvedValue(false),
}; };
const { hasActiveUsers, updateUserById } = userQueries; const { hasActiveUsers } = userQueries;
const userLibraries = { const userLibraries = {
generateUserId: jest.fn().mockResolvedValue('uid'), generateUserId: jest.fn().mockResolvedValue('uid'),
insertUser: jest.fn().mockResolvedValue([{}, { organizationIds: [] }]), insertUser: jest.fn().mockResolvedValue([{}, { organizationIds: [] }]),
updateUserById: jest.fn(),
}; };
const { generateUserId, insertUser } = userLibraries; const { generateUserId, insertUser, updateUserById } = userLibraries;
const submitInteraction = await pickDefault(import('./submit-interaction.js')); const submitInteraction = await pickDefault(import('./submit-interaction.js'));
const now = Date.now(); const now = Date.now();

View file

@ -53,21 +53,21 @@ const userQueries = {
identities: { google: { userId: 'googleId', details: {} } }, identities: { google: { userId: 'googleId', details: {} } },
mfaVerifications: [], mfaVerifications: [],
}), }),
updateUserById: jest.fn(async (id: string, user: Partial<User>) => user as User),
hasActiveUsers: jest.fn().mockResolvedValue(true), hasActiveUsers: jest.fn().mockResolvedValue(true),
hasUserWithEmail: jest.fn().mockResolvedValue(false), hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false), hasUserWithPhone: jest.fn().mockResolvedValue(false),
}; };
const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = userQueries; const { hasActiveUsers, hasUserWithEmail, hasUserWithPhone } = userQueries;
const userLibraries = { const userLibraries = {
generateUserId: jest.fn().mockResolvedValue('uid'), generateUserId: jest.fn().mockResolvedValue('uid'),
insertUser: jest.fn( insertUser: jest.fn(
async (user: CreateUser): Promise<InsertUserResult> => [user as User, { organizationIds: [] }] async (user: CreateUser): Promise<InsertUserResult> => [user as User, { organizationIds: [] }]
), ),
updateUserById: jest.fn(async (id: string, user: Partial<User>) => user as User),
}; };
const { generateUserId, insertUser } = userLibraries; const { generateUserId, insertUser, updateUserById } = userLibraries;
const submitInteraction = await pickDefault(import('./submit-interaction.js')); const submitInteraction = await pickDefault(import('./submit-interaction.js'));
const now = Date.now(); const now = Date.now();

View file

@ -213,8 +213,9 @@ async function handleSubmitSignIn(
tenantContext: TenantContext, tenantContext: TenantContext,
log?: LogEntry log?: LogEntry
) { ) {
const { provider, queries } = tenantContext; const { provider, queries, libraries } = tenantContext;
const { findUserById, updateUserById } = queries.users; const { findUserById } = queries.users;
const { updateUserById } = libraries.users;
const { accountId } = interaction; const { accountId } = interaction;
log?.append({ userId: accountId }); log?.append({ userId: accountId });
@ -262,8 +263,8 @@ export default async function submitInteraction(
tenantContext: TenantContext, tenantContext: TenantContext,
log?: LogEntry log?: LogEntry
) { ) {
const { provider, queries } = tenantContext; const { provider, libraries } = tenantContext;
const { updateUserById } = queries.users; const { updateUserById } = libraries.users;
const { event, profile } = interaction; const { event, profile } = interaction;
if (event === InteractionEvent.Register) { if (event === InteractionEvent.Register) {

View file

@ -76,6 +76,11 @@ const tenantContext = new MockTenant(
}, },
users: { users: {
findUserById: jest.fn().mockResolvedValue(mockUserWithMfaVerifications), findUserById: jest.fn().mockResolvedValue(mockUserWithMfaVerifications),
},
},
undefined,
{
users: {
updateUserById, updateUserById,
}, },
} }

View file

@ -30,7 +30,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>, router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
tenant: TenantContext tenant: TenantContext
) { ) {
const { provider, queries } = tenant; const { provider, queries, libraries } = tenant;
// Set New MFA // Set New MFA
router.post( router.post(
@ -131,7 +131,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
// Update last used time // Update last used time
const user = await queries.users.findUserById(accountId); const user = await queries.users.findUserById(accountId);
await queries.users.updateUserById(accountId, { await libraries.users.updateUserById(accountId, {
mfaVerifications: user.mfaVerifications.map((mfa) => { mfaVerifications: user.mfaVerifications.map((mfa) => {
if (mfa.id !== verifiedMfa.id) { if (mfa.id !== verifiedMfa.id) {
return mfa; return mfa;

View file

@ -80,7 +80,6 @@ describe('Single sign on util methods tests', () => {
insert: insertUserSsoIdentityMock, insert: insertUserSsoIdentityMock,
}, },
users: { users: {
updateUserById: updateUserMock,
findUserByEmail: findUserByEmailMock, findUserByEmail: findUserByEmailMock,
}, },
}, },
@ -89,6 +88,7 @@ describe('Single sign on util methods tests', () => {
users: { users: {
insertUser: insertUserMock, insertUser: insertUserMock,
generateUserId: generateUserIdMock, generateUserId: generateUserIdMock,
updateUserById: updateUserMock,
}, },
ssoConnectors: { ssoConnectors: {
getAvailableSsoConnectors: getAvailableSsoConnectorsMock, getAvailableSsoConnectors: getAvailableSsoConnectorsMock,

View file

@ -14,6 +14,7 @@ import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import { type WithInteractionDetailsContext } from '#src/routes/interaction/middleware/koa-interaction-details.js'; import { type WithInteractionDetailsContext } from '#src/routes/interaction/middleware/koa-interaction-details.js';
import { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/index.js'; import { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
@ -139,7 +140,7 @@ export const handleSsoAuthentication = async (
ssoAuthentication: SsoAuthenticationResult ssoAuthentication: SsoAuthenticationResult
): Promise<string> => { ): Promise<string> => {
const { createLog } = ctx; const { createLog } = ctx;
const { provider, queries } = tenant; const { provider, queries, libraries } = tenant;
const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = queries; const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = queries;
const { issuer, userInfo } = ssoAuthentication; const { issuer, userInfo } = ssoAuthentication;
@ -151,7 +152,7 @@ export const handleSsoAuthentication = async (
// SignIn // SignIn
if (userSsoIdentity) { if (userSsoIdentity) {
return signInWithSsoAuthentication(ctx, queries, { return signInWithSsoAuthentication(ctx, queries, libraries, {
connectorData, connectorData,
userSsoIdentity, userSsoIdentity,
ssoAuthentication, ssoAuthentication,
@ -162,7 +163,7 @@ export const handleSsoAuthentication = async (
// SignIn and link with existing user account with a same email // SignIn and link with existing user account with a same email
if (user) { if (user) {
return signInAndLinkWithSsoAuthentication(ctx, queries, { return signInAndLinkWithSsoAuthentication(ctx, queries, libraries, {
connectorData, connectorData,
user, user,
ssoAuthentication, ssoAuthentication,
@ -180,7 +181,8 @@ export const handleSsoAuthentication = async (
const signInWithSsoAuthentication = async ( const signInWithSsoAuthentication = async (
ctx: WithLogContext, ctx: WithLogContext,
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries, { userSsoIdentities: userSsoIdentitiesQueries }: Queries,
{ users: usersLibraries }: Libraries,
{ {
connectorData: { id: connectorId, syncProfile }, connectorData: { id: connectorId, syncProfile },
userSsoIdentity: { id, userId }, userSsoIdentity: { id, userId },
@ -211,7 +213,7 @@ const signInWithSsoAuthentication = async (
} }
: undefined; : undefined;
await usersQueries.updateUserById(userId, { await usersLibraries.updateUserById(userId, {
...syncingProfile, ...syncingProfile,
lastSignInAt: Date.now(), lastSignInAt: Date.now(),
}); });
@ -234,7 +236,8 @@ const signInWithSsoAuthentication = async (
const signInAndLinkWithSsoAuthentication = async ( const signInAndLinkWithSsoAuthentication = async (
ctx: WithLogContext, ctx: WithLogContext,
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries, { userSsoIdentities: userSsoIdentitiesQueries }: Queries,
{ users: usersLibraries }: Libraries,
{ {
connectorData: { id: connectorId, syncProfile }, connectorData: { id: connectorId, syncProfile },
user: { id: userId }, user: { id: userId },
@ -269,7 +272,7 @@ const signInAndLinkWithSsoAuthentication = async (
} }
: undefined; : undefined;
await usersQueries.updateUserById(userId, { await usersLibraries.updateUserById(userId, {
...syncingProfile, ...syncingProfile,
lastSignInAt: Date.now(), lastSignInAt: Date.now(),
}); });

View file

@ -24,12 +24,20 @@ const { mockEsm } = createMockUtils(jest);
const findUserById = jest.fn(); const findUserById = jest.fn();
const updateUserById = jest.fn(); const updateUserById = jest.fn();
const tenantContext = new MockTenant(undefined, { const tenantContext = new MockTenant(
undefined,
{
users: { users: {
findUserById, findUserById,
},
},
undefined,
{
users: {
updateUserById, updateUserById,
}, },
}); }
);
const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({ const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({
validateTotpToken: jest.fn().mockReturnValue(true), validateTotpToken: jest.fn().mockReturnValue(true),

View file

@ -230,7 +230,7 @@ export async function verifyMfaPayloadVerification(
if (newCounter !== undefined) { if (newCounter !== undefined) {
// Update the authenticator's counter in the DB to the newest count in the authentication // Update the authenticator's counter in the DB to the newest count in the authentication
await tenant.queries.users.updateUserById(accountId, { await tenant.libraries.users.updateUserById(accountId, {
mfaVerifications: user.mfaVerifications.map((mfa) => { mfaVerifications: user.mfaVerifications.map((mfa) => {
if (mfa.type !== MfaFactor.WebAuthn || mfa.id !== result.id) { if (mfa.type !== MfaFactor.WebAuthn || mfa.id !== result.id) {
return mfa; return mfa;
@ -250,7 +250,7 @@ export async function verifyMfaPayloadVerification(
const { id, type } = await verifyBackupCode(user.mfaVerifications, verifyMfaPayload); const { id, type } = await verifyBackupCode(user.mfaVerifications, verifyMfaPayload);
// Mark the backup code as used // Mark the backup code as used
await tenant.queries.users.updateUserById(accountId, { await tenant.libraries.users.updateUserById(accountId, {
mfaVerifications: user.mfaVerifications.map((mfa) => { mfaVerifications: user.mfaVerifications.map((mfa) => {
if (mfa.id !== id || mfa.type !== MfaFactor.BackupCode) { if (mfa.id !== id || mfa.type !== MfaFactor.BackupCode) {
return mfa; return mfa;

View file

@ -28,12 +28,20 @@ const { mockEsmWithActual } = createMockUtils(jest);
const findUserById = jest.fn(); const findUserById = jest.fn();
const updateUserById = jest.fn(); const updateUserById = jest.fn();
const tenantContext = new MockTenant(undefined, { const tenantContext = new MockTenant(
undefined,
{
users: { users: {
findUserById, findUserById,
},
},
undefined,
{
users: {
updateUserById, updateUserById,
}, },
}); }
);
const mockBackupCodes = ['foo']; const mockBackupCodes = ['foo'];
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({ await mockEsmWithActual('../utils/backup-code-validation.js', () => ({

View file

@ -28,12 +28,18 @@ const { mockEsmWithActual } = createMockUtils(jest);
const findUserById = jest.fn(); const findUserById = jest.fn();
const updateUserById = jest.fn(); const updateUserById = jest.fn();
const tenantContext = new MockTenant(undefined, { const tenantContext = new MockTenant(
undefined,
{
users: { users: {
findUserById, findUserById,
updateUserById,
}, },
}); },
undefined,
{
users: { updateUserById },
}
);
const mockBackupCodes = ['foo']; const mockBackupCodes = ['foo'];
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({ await mockEsmWithActual('../utils/backup-code-validation.js', () => ({

View file

@ -1,4 +1,8 @@
import { MfaFactor, type User, type UserMfaVerificationResponse } from '@logto/schemas'; import { MfaFactor, type User, type UserMfaVerificationResponse } from '@logto/schemas';
import { parseE164PhoneNumberWithError, ParseError } from '@logto/shared/universal';
import { tryThat } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
export const transpileUserMfaVerifications = ( export const transpileUserMfaVerifications = (
mfaVerifications: User['mfaVerifications'] mfaVerifications: User['mfaVerifications']
@ -21,3 +25,25 @@ export const transpileUserMfaVerifications = (
return { id, createdAt, type }; return { id, createdAt, type };
}); });
}; };
export const getValidPhoneNumber = (phone: string) =>
tryThat(
() => {
if (!phone) {
return phone;
}
const phoneNumber = parseE164PhoneNumberWithError(phone);
if (!phoneNumber.isValid()) {
throw new RequestError({ code: 'user.invalid_phone', status: 422 });
}
return phoneNumber.number.slice(1);
},
(error: unknown) => {
if (error instanceof ParseError) {
throw new RequestError({ code: 'user.invalid_phone', status: 422 }, error);
}
throw error;
}
);

View file

@ -27,6 +27,7 @@
"@logto/phrases": "workspace:^1.11.0", "@logto/phrases": "workspace:^1.11.0",
"@logto/phrases-experience": "workspace:^1.6.1", "@logto/phrases-experience": "workspace:^1.6.1",
"@logto/schemas": "workspace:^1.17.0", "@logto/schemas": "workspace:^1.17.0",
"@logto/shared": "workspace:^3.1.0",
"@parcel/compressor-brotli": "2.9.3", "@parcel/compressor-brotli": "2.9.3",
"@parcel/compressor-gzip": "2.9.3", "@parcel/compressor-gzip": "2.9.3",
"@parcel/core": "2.9.3", "@parcel/core": "2.9.3",
@ -67,7 +68,7 @@
"jest-transformer-svg": "^2.0.0", "jest-transformer-svg": "^2.0.0",
"js-base64": "^3.7.5", "js-base64": "^3.7.5",
"ky": "^1.2.3", "ky": "^1.2.3",
"libphonenumber-js": "^1.10.51", "libphonenumber-js": "^1.11.1",
"lint-staged": "^15.0.0", "lint-staged": "^15.0.0",
"parcel": "2.9.3", "parcel": "2.9.3",
"parcel-resolver-ignore": "^2.1.3", "parcel-resolver-ignore": "^2.1.3",

View file

@ -1,10 +1,7 @@
import { parseE164PhoneNumberWithError } from '@logto/shared/universal';
import i18next from 'i18next'; import i18next from 'i18next';
import type { CountryCode, CountryCallingCode, E164Number } from 'libphonenumber-js/mobile'; import type { CountryCode, CountryCallingCode } from 'libphonenumber-js/mobile';
import { import { getCountries, getCountryCallingCode } from 'libphonenumber-js/mobile';
getCountries,
getCountryCallingCode,
parsePhoneNumberWithError,
} from 'libphonenumber-js/mobile';
export const fallbackCountryCode = 'US'; export const fallbackCountryCode = 'US';
@ -86,17 +83,9 @@ export const getCountryList = (): CountryMetaData[] => {
]; ];
}; };
export const parseE164Number = (value: string): E164Number | '' => {
if (!value || value.startsWith('+')) {
return value;
}
return `+${value}`;
};
export const formatPhoneNumberWithCountryCallingCode = (number: string) => { export const formatPhoneNumberWithCountryCallingCode = (number: string) => {
try { try {
const phoneNumber = parsePhoneNumberWithError(parseE164Number(number)); const phoneNumber = parseE164PhoneNumberWithError(number);
return `+${phoneNumber.countryCallingCode} ${phoneNumber.nationalNumber}`; return `+${phoneNumber.countryCallingCode} ${phoneNumber.nationalNumber}`;
} catch { } catch {
@ -106,7 +95,7 @@ export const formatPhoneNumberWithCountryCallingCode = (number: string) => {
export const parsePhoneNumber = (value: string) => { export const parsePhoneNumber = (value: string) => {
try { try {
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value)); const phoneNumber = parseE164PhoneNumberWithError(value);
return { return {
countryCallingCode: phoneNumber.countryCallingCode, countryCallingCode: phoneNumber.countryCallingCode,

View file

@ -1,12 +1,13 @@
import { usernameRegEx, emailRegEx } from '@logto/core-kit'; import { usernameRegEx, emailRegEx } from '@logto/core-kit';
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { parseE164PhoneNumberWithError } from '@logto/shared/universal';
import i18next from 'i18next'; import i18next from 'i18next';
import type { TFuncKey } from 'i18next'; import type { TFuncKey } from 'i18next';
import { parsePhoneNumberWithError, ParseError } from 'libphonenumber-js/mobile'; import { ParseError } from 'libphonenumber-js/mobile';
import type { ErrorType } from '@/components/ErrorMessage'; import type { ErrorType } from '@/components/ErrorMessage';
import type { IdentifierInputType } from '@/components/InputFields/SmartInputField'; import type { IdentifierInputType } from '@/components/InputFields/SmartInputField';
import { parseE164Number, parsePhoneNumber } from '@/utils/country-code'; import { parsePhoneNumber } from '@/utils/country-code';
const { t } = i18next; const { t } = i18next;
@ -32,7 +33,7 @@ export const validateEmail = (email: string): ErrorType | undefined => {
export const validatePhone = (value: string): ErrorType | undefined => { export const validatePhone = (value: string): ErrorType | undefined => {
try { try {
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value)); const phoneNumber = parseE164PhoneNumberWithError(value);
if (!phoneNumber.isValid()) { if (!phoneNumber.isValid()) {
return 'invalid_phone'; return 'invalid_phone';

View file

@ -35,7 +35,6 @@ describe('admin console user search params', () => {
'jerry swift jr jr', 'jerry swift jr jr',
]; ];
const emailSuffix = ['@gmail.com', '@foo.bar', '@geek.best']; const emailSuffix = ['@gmail.com', '@foo.bar', '@geek.best'];
const phonePrefix = ['101', '102', '202'];
// eslint-disable-next-line @silverhand/fp/no-mutation // eslint-disable-next-line @silverhand/fp/no-mutation
users = await Promise.all( users = await Promise.all(
@ -47,8 +46,7 @@ describe('admin console user search params', () => {
.map((segment) => segment[0]!.toUpperCase() + segment.slice(1)) .map((segment) => segment[0]!.toUpperCase() + segment.slice(1))
.join(' '); .join(' ');
const primaryEmail = username + emailSuffix[index % emailSuffix.length]!; const primaryEmail = username + emailSuffix[index % emailSuffix.length]!;
const primaryPhone = const primaryPhone = '1310805' + index.toString().padStart(4, '0');
phonePrefix[index % phonePrefix.length]! + index.toString().padStart(5, '0');
return createUserByAdmin({ username: prefix + username, primaryEmail, primaryPhone, name }); return createUserByAdmin({ username: prefix + username, primaryEmail, primaryPhone, name });
}) })
@ -74,7 +72,7 @@ describe('admin console user search params', () => {
}); });
it('should search primaryPhone', async () => { it('should search primaryPhone', async () => {
const { headers, json } = await getUsers<User[]>([['search', '%0000%']]); const { headers, json } = await getUsers<User[]>([['search', '%000%']]);
expect(headers.get('total-number')).toEqual('10'); expect(headers.get('total-number')).toEqual('10');
expect( expect(

View file

@ -155,6 +155,14 @@ describe('admin console user management', () => {
}); });
}); });
it('should respond 422 when update user with invalid phone', async () => {
const user = await createUserByAdmin();
await expectRejects(updateUser(user.id, { primaryPhone: '13110000000' }), {
code: 'user.invalid_phone',
status: 422,
});
});
it('should fail when update userinfo with conflict identifiers', async () => { it('should fail when update userinfo with conflict identifiers', async () => {
const [username, primaryEmail, primaryPhone] = [ const [username, primaryEmail, primaryPhone] = [
generateUsername(), generateUsername(),

View file

@ -11,6 +11,7 @@ import {
} from '#src/api/role.js'; } from '#src/api/role.js';
import { expectRejects } from '#src/helpers/index.js'; import { expectRejects } from '#src/helpers/index.js';
import { generateNewUserProfile } from '#src/helpers/user.js'; import { generateNewUserProfile } from '#src/helpers/user.js';
import { generatePhone } from '#src/utils.js';
describe('roles users', () => { describe('roles users', () => {
it('should get role users successfully and can get roles correctly (specifying exclude user)', async () => { it('should get role users successfully and can get roles correctly (specifying exclude user)', async () => {
@ -38,7 +39,14 @@ describe('roles users', () => {
name: 'user001', name: 'user001',
primaryEmail: 'user001@logto.io', primaryEmail: 'user001@logto.io',
}); });
const user2 = await createUser({ name: 'user002', primaryPhone: '123456789' });
// Can not create user with invalid phone number.
await expectRejects(createUser({ name: 'user002', primaryPhone: '123456789' }), {
code: 'user.invalid_phone',
status: 422,
});
const user2 = await createUser({ name: 'user002', primaryPhone: generatePhone() });
const user3 = await createUser({ username: 'username3', primaryEmail: 'user3@logto.io' }); const user3 = await createUser({ username: 'username3', primaryEmail: 'user3@logto.io' });
await assignUsersToRole([user1.id, user2.id, user3.id], role.id); await assignUsersToRole([user1.id, user2.id, user3.id], role.id);

View file

@ -62,7 +62,7 @@
"@silverhand/essentials": "^2.9.1", "@silverhand/essentials": "^2.9.1",
"chalk": "^5.0.0", "chalk": "^5.0.0",
"find-up": "^7.0.0", "find-up": "^7.0.0",
"libphonenumber-js": "^1.9.49", "libphonenumber-js": "^1.11.1",
"nanoid": "^5.0.1" "nanoid": "^5.0.1"
} }
} }

View file

@ -1,4 +1,27 @@
import { parsePhoneNumberWithError } from 'libphonenumber-js'; import { parsePhoneNumberWithError } from 'libphonenumber-js/mobile';
import type { E164Number } from 'libphonenumber-js/mobile';
export { ParseError } from 'libphonenumber-js/mobile';
function validateE164Number(value: string): asserts value is E164Number {
if (value && !value.startsWith('+')) {
throw new TypeError(`Invalid E164Number: ${value}`);
}
}
const parseE164Number = (value: string): E164Number | '' => {
// If typeof `value` is string and `!value` is true, then `value` is an empty string.
if (!value) {
return '';
}
const result = value.startsWith('+') ? value : `+${value}`;
validateE164Number(result);
return result;
};
export const parseE164PhoneNumberWithError = (value: string) =>
parsePhoneNumberWithError(parseE164Number(value));
/** /**
* Parse phone number to number string. * Parse phone number to number string.
@ -6,7 +29,7 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js';
*/ */
export const parsePhoneNumber = (phone: string) => { export const parsePhoneNumber = (phone: string) => {
try { try {
return parsePhoneNumberWithError(phone).number.slice(1); return parseE164PhoneNumberWithError(phone).number.slice(1);
} catch { } catch {
console.error(`Invalid phone number: ${phone}`); console.error(`Invalid phone number: ${phone}`);
return phone; return phone;
@ -19,8 +42,7 @@ export const parsePhoneNumber = (phone: string) => {
*/ */
export const formatToInternationalPhoneNumber = (phone: string) => { export const formatToInternationalPhoneNumber = (phone: string) => {
try { try {
const phoneNumber = phone.startsWith('+') ? phone : `+${phone}`; return parseE164PhoneNumberWithError(phone).formatInternational();
return parsePhoneNumberWithError(phoneNumber).formatInternational();
} catch { } catch {
console.error(`Invalid phone number: ${phone}`); console.error(`Invalid phone number: ${phone}`);
return phone; return phone;

View file

@ -3032,8 +3032,8 @@ importers:
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3 version: 1.2.3
libphonenumber-js: libphonenumber-js:
specifier: ^1.10.51 specifier: ^1.11.1
version: 1.10.51 version: 1.11.1
lint-staged: lint-staged:
specifier: ^15.0.0 specifier: ^15.0.0
version: 15.0.2 version: 15.0.2
@ -3563,6 +3563,9 @@ importers:
'@logto/schemas': '@logto/schemas':
specifier: workspace:^1.17.0 specifier: workspace:^1.17.0
version: link:../schemas version: link:../schemas
'@logto/shared':
specifier: workspace:^3.1.0
version: link:../shared
'@parcel/compressor-brotli': '@parcel/compressor-brotli':
specifier: 2.9.3 specifier: 2.9.3
version: 2.9.3(@parcel/core@2.9.3) version: 2.9.3(@parcel/core@2.9.3)
@ -3684,8 +3687,8 @@ importers:
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3 version: 1.2.3
libphonenumber-js: libphonenumber-js:
specifier: ^1.10.51 specifier: ^1.11.1
version: 1.10.51 version: 1.11.1
lint-staged: lint-staged:
specifier: ^15.0.0 specifier: ^15.0.0
version: 15.0.2 version: 15.0.2
@ -4012,8 +4015,8 @@ importers:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0 version: 7.0.0
libphonenumber-js: libphonenumber-js:
specifier: ^1.9.49 specifier: ^1.11.1
version: 1.10.51 version: 1.11.1
nanoid: nanoid:
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1 version: 5.0.1
@ -9897,8 +9900,8 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
libphonenumber-js@1.10.51: libphonenumber-js@1.11.1:
resolution: {integrity: sha512-vY2I+rQwrDQzoPds0JeTEpeWzbUJgqoV0O4v31PauHBb/e+1KCXKylHcDnBMgJZ9fH9mErsEbROJY3Z3JtqEmg==} resolution: {integrity: sha512-Wze1LPwcnzvcKGcRHFGFECTaLzxOtujwpf924difr5zniyYv1C2PiW0419qDR7m8lKDxsImu5mwxFuXhXpjmvw==}
lightningcss-darwin-arm64@1.16.1: lightningcss-darwin-arm64@1.16.1:
resolution: {integrity: sha512-/J898YSAiGVqdybHdIF3Ao0Hbh2vyVVj5YNm3NznVzTSvkOi3qQCAtO97sfmNz+bSRHXga7ZPLm+89PpOM5gAg==} resolution: {integrity: sha512-/J898YSAiGVqdybHdIF3Ao0Hbh2vyVVj5YNm3NznVzTSvkOi3qQCAtO97sfmNz+bSRHXga7ZPLm+89PpOM5gAg==}
@ -20790,7 +20793,7 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
libphonenumber-js@1.10.51: {} libphonenumber-js@1.11.1: {}
lightningcss-darwin-arm64@1.16.1: lightningcss-darwin-arm64@1.16.1:
optional: true optional: true