mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat: add phone number validation to user APIs
This commit is contained in:
parent
136320584f
commit
6cc51d098b
33 changed files with 273 additions and 141 deletions
|
@ -86,7 +86,7 @@
|
|||
"jest-transformer-svg": "^2.0.0",
|
||||
"just-kebab-case": "^4.2.0",
|
||||
"ky": "^1.2.3",
|
||||
"libphonenumber-js": "^1.10.51",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
"lint-staged": "^15.0.0",
|
||||
"nanoid": "^5.0.1",
|
||||
"overlayscrollbars": "^2.0.2",
|
||||
|
|
|
@ -2,7 +2,7 @@ import { emailRegEx, usernameRegEx } from '@logto/core-kit';
|
|||
import type { User } from '@logto/schemas';
|
||||
import { parsePhoneNumber } from '@logto/shared/universal';
|
||||
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 { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
|
@ -186,11 +186,15 @@ describe('verifyUserPassword()', () => {
|
|||
};
|
||||
it('migrates password to Argon2', async () => {
|
||||
await verifyUserPassword(user, 'password');
|
||||
expect(updateUserById).toHaveBeenCalledWith(user.id, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
passwordEncrypted: expect.stringContaining('argon2'),
|
||||
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i,
|
||||
});
|
||||
expect(updateUserById).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
passwordEncrypted: expect.stringContaining('argon2'),
|
||||
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -220,6 +224,7 @@ describe('addUserMfaVerification()', () => {
|
|||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(createdAt));
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
@ -227,10 +232,19 @@ describe('addUserMfaVerification()', () => {
|
|||
});
|
||||
|
||||
it('update user with new mfa verification', async () => {
|
||||
await addUserMfaVerification(mockUser.id, { type: MfaFactor.TOTP, secret: 'secret' });
|
||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
mfaVerifications: [{ type: MfaFactor.TOTP, key: 'secret', id: expect.anything(), createdAt }],
|
||||
await addUserMfaVerification(mockUser.id, {
|
||||
type: MfaFactor.TOTP,
|
||||
secret: 'secret',
|
||||
});
|
||||
expect(updateUserById).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
{
|
||||
mfaVerifications: [
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
{ type: MfaFactor.TOTP, key: 'secret', id: expect.anything(), createdAt },
|
||||
],
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import type { BindMfa, CreateUser, MfaVerification, Scope, User } from '@logto/schemas';
|
||||
import { MfaFactor, RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||
import { generateStandardId, generateStandardShortId } from '@logto/shared';
|
||||
import { deduplicateByKey, type Nullable } from '@silverhand/essentials';
|
||||
import { generateStandardShortId, generateStandardId } from '@logto/shared';
|
||||
import type { Nullable } from '@silverhand/essentials';
|
||||
import { deduplicateByKey, conditional } from '@silverhand/essentials';
|
||||
import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm';
|
||||
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 { encryptPassword } from '#src/utils/password.js';
|
||||
import type { OmitAutoSetFields } from '#src/utils/sql.js';
|
||||
import { getValidPhoneNumber } from '#src/utils/user.js';
|
||||
|
||||
export const encryptUserPassword = async (
|
||||
password: string
|
||||
|
@ -90,7 +92,7 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
hasUserWithId,
|
||||
hasUserWithPhone,
|
||||
findUsersByIds,
|
||||
updateUserById,
|
||||
updateUserById: updateUserByIdQuery,
|
||||
findUserById,
|
||||
},
|
||||
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
|
||||
|
@ -115,6 +117,24 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
{ 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 (
|
||||
data: OmitAutoSetFields<CreateUser>,
|
||||
additionalRoleNames: string[]
|
||||
|
@ -127,12 +147,21 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
|
||||
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) => {
|
||||
const insertUserQuery = buildInsertIntoWithPool(connection)(Users, {
|
||||
returning: true,
|
||||
});
|
||||
|
||||
const user = await insertUserQuery(data);
|
||||
const user = await insertUserQuery({
|
||||
...data,
|
||||
...conditional(validPhoneNumber && { primaryPhone: validPhoneNumber }),
|
||||
});
|
||||
const roles = deduplicateByKey([...parameterRoles, ...defaultRoles], 'id');
|
||||
|
||||
if (roles.length > 0) {
|
||||
|
@ -336,5 +365,6 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
verifyUserPassword,
|
||||
signOutUser,
|
||||
findUserSsoIdentities,
|
||||
updateUserById,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -22,9 +22,12 @@ export default function socialRoutes<T extends AuthedMeRouter>(
|
|||
) {
|
||||
const {
|
||||
queries: {
|
||||
users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity },
|
||||
users: { findUserById, deleteUserIdentity, hasUserWithIdentity },
|
||||
signInExperiences: { findDefaultSignInExperience },
|
||||
},
|
||||
libraries: {
|
||||
users: { updateUserById },
|
||||
},
|
||||
connectors: { getLogtoConnectors, getLogtoConnectorById },
|
||||
} = tenant;
|
||||
|
||||
|
|
|
@ -17,10 +17,10 @@ export default function userRoutes<T extends AuthedMeRouter>(
|
|||
) {
|
||||
const {
|
||||
queries: {
|
||||
users: { findUserById, updateUserById },
|
||||
users: { findUserById },
|
||||
},
|
||||
libraries: {
|
||||
users: { checkIdentifierCollision, verifyUserPassword },
|
||||
users: { checkIdentifierCollision, verifyUserPassword, updateUserById },
|
||||
verificationStatuses: { createVerificationStatus, checkVerificationStatus },
|
||||
},
|
||||
} = tenant;
|
||||
|
|
|
@ -35,12 +35,6 @@ const mockedQueries = {
|
|||
hasUser: jest.fn(async () => mockHasUser()),
|
||||
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
|
||||
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
|
||||
updateUserById: jest.fn(
|
||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||
...mockUser,
|
||||
...data,
|
||||
})
|
||||
),
|
||||
deleteUserById: jest.fn(),
|
||||
deleteUserIdentity: jest.fn(),
|
||||
},
|
||||
|
@ -67,8 +61,7 @@ const mockHasUser = jest.fn(async () => false);
|
|||
const mockHasUserWithEmail = jest.fn(async () => false);
|
||||
const mockHasUserWithPhone = jest.fn(async () => false);
|
||||
|
||||
const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } =
|
||||
mockedQueries.users;
|
||||
const { hasUser, findUserById, deleteUserIdentity, deleteUserById } = mockedQueries.users;
|
||||
|
||||
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
|
||||
encryptUserPassword: jest.fn(() => ({
|
||||
|
@ -92,8 +85,16 @@ const usersLibraries = {
|
|||
),
|
||||
verifyUserPassword,
|
||||
signOutUser,
|
||||
updateUserById: jest.fn(
|
||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||
...mockUser,
|
||||
...data,
|
||||
})
|
||||
),
|
||||
} satisfies Partial<Libraries['users']>;
|
||||
|
||||
const { updateUserById } = usersLibraries;
|
||||
|
||||
const adminUserRoutes = await pickDefault(import('./basics.js'));
|
||||
|
||||
describe('adminUserRoutes', () => {
|
||||
|
|
|
@ -23,14 +23,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
) {
|
||||
const [router, { queries, libraries }] = args;
|
||||
const {
|
||||
users: {
|
||||
deleteUserById,
|
||||
findUserById,
|
||||
hasUser,
|
||||
updateUserById,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
},
|
||||
users: { deleteUserById, findUserById, hasUser, hasUserWithEmail, hasUserWithPhone },
|
||||
} = queries;
|
||||
const {
|
||||
users: {
|
||||
|
@ -40,6 +33,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
verifyUserPassword,
|
||||
signOutUser,
|
||||
findUserSsoIdentities,
|
||||
updateUserById,
|
||||
},
|
||||
} = libraries;
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ import {
|
|||
mockUserTotpMfaVerification,
|
||||
mockUserWithMfaVerifications,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import { type InsertUserResult } from '#src/libraries/user.js';
|
||||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
|
||||
|
@ -23,12 +22,6 @@ const mockedQueries = {
|
|||
hasUser: jest.fn(async () => mockHasUser()),
|
||||
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
|
||||
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
|
||||
updateUserById: jest.fn(
|
||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||
...mockUser,
|
||||
...data,
|
||||
})
|
||||
),
|
||||
deleteUserById: jest.fn(),
|
||||
deleteUserIdentity: jest.fn(),
|
||||
},
|
||||
|
@ -38,7 +31,7 @@ const mockHasUser = jest.fn(async () => false);
|
|||
const mockHasUserWithEmail = 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', () => ({
|
||||
generateTotpSecret: jest.fn().mockReturnValue('totp_secret'),
|
||||
|
@ -47,25 +40,31 @@ await mockEsmWithActual('../interaction/utils/backup-code-validation.js', () =>
|
|||
generateBackupCodes: jest.fn().mockReturnValue(['code']),
|
||||
}));
|
||||
|
||||
const usersLibraries = {
|
||||
generateUserId: jest.fn(async () => 'fooId'),
|
||||
insertUser: jest.fn(
|
||||
async (user: CreateUser): Promise<InsertUserResult> => [
|
||||
{
|
||||
const mockLibraries = {
|
||||
users: {
|
||||
generateUserId: jest.fn(async () => 'fooId'),
|
||||
insertUser: jest.fn(
|
||||
async (user: CreateUser): Promise<User> => ({
|
||||
...mockUser,
|
||||
...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'));
|
||||
|
||||
describe('adminUserRoutes', () => {
|
||||
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, {
|
||||
users: usersLibraries,
|
||||
});
|
||||
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, mockLibraries);
|
||||
const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext });
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
@ -21,12 +21,12 @@ export default function adminUserMfaVerificationsRoutes<T extends ManagementApiR
|
|||
{
|
||||
queries,
|
||||
libraries: {
|
||||
users: { addUserMfaVerification },
|
||||
users: { addUserMfaVerification, updateUserById },
|
||||
},
|
||||
},
|
||||
] = args;
|
||||
const {
|
||||
users: { findUserById, updateUserById },
|
||||
users: { findUserById },
|
||||
} = queries;
|
||||
|
||||
router.get(
|
||||
|
|
|
@ -34,12 +34,6 @@ const mockedQueries = {
|
|||
}
|
||||
return mockUser;
|
||||
}),
|
||||
updateUserById: jest.fn(
|
||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||
...mockUser,
|
||||
...data,
|
||||
})
|
||||
),
|
||||
hasUserWithIdentity: mockHasUserWithIdentity,
|
||||
deleteUserById: jest.fn(),
|
||||
deleteUserIdentity: jest.fn(),
|
||||
|
@ -57,6 +51,12 @@ const usersLibraries = {
|
|||
{ organizationIds: [] },
|
||||
]
|
||||
),
|
||||
updateUserById: jest.fn(
|
||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||
...mockUser,
|
||||
...data,
|
||||
})
|
||||
),
|
||||
} satisfies Partial<Libraries['users']>;
|
||||
|
||||
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'));
|
||||
|
||||
|
|
|
@ -20,7 +20,10 @@ export default function adminUserSocialRoutes<T extends ManagementApiRouter>(
|
|||
) {
|
||||
const {
|
||||
queries: {
|
||||
users: { findUserById, updateUserById, hasUserWithIdentity, deleteUserIdentity },
|
||||
users: { findUserById, hasUserWithIdentity, deleteUserIdentity },
|
||||
},
|
||||
libraries: {
|
||||
users: { updateUserById },
|
||||
},
|
||||
connectors: { getLogtoConnectorById },
|
||||
} = tenant;
|
||||
|
|
|
@ -44,19 +44,19 @@ const userQueries = {
|
|||
identities: { google: { userId: 'googleId', details: {} } },
|
||||
mfaVerifications: [],
|
||||
}),
|
||||
updateUserById: jest.fn(),
|
||||
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
||||
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
||||
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
const { hasActiveUsers, updateUserById } = userQueries;
|
||||
const { hasActiveUsers } = userQueries;
|
||||
|
||||
const userLibraries = {
|
||||
generateUserId: jest.fn().mockResolvedValue('uid'),
|
||||
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 now = Date.now();
|
||||
|
|
|
@ -53,21 +53,21 @@ const userQueries = {
|
|||
identities: { google: { userId: 'googleId', details: {} } },
|
||||
mfaVerifications: [],
|
||||
}),
|
||||
updateUserById: jest.fn(async (id: string, user: Partial<User>) => user as User),
|
||||
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
||||
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
||||
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = userQueries;
|
||||
const { hasActiveUsers, hasUserWithEmail, hasUserWithPhone } = userQueries;
|
||||
|
||||
const userLibraries = {
|
||||
generateUserId: jest.fn().mockResolvedValue('uid'),
|
||||
insertUser: jest.fn(
|
||||
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 now = Date.now();
|
||||
|
|
|
@ -213,8 +213,9 @@ async function handleSubmitSignIn(
|
|||
tenantContext: TenantContext,
|
||||
log?: LogEntry
|
||||
) {
|
||||
const { provider, queries } = tenantContext;
|
||||
const { findUserById, updateUserById } = queries.users;
|
||||
const { provider, queries, libraries } = tenantContext;
|
||||
const { findUserById } = queries.users;
|
||||
const { updateUserById } = libraries.users;
|
||||
|
||||
const { accountId } = interaction;
|
||||
log?.append({ userId: accountId });
|
||||
|
@ -262,8 +263,8 @@ export default async function submitInteraction(
|
|||
tenantContext: TenantContext,
|
||||
log?: LogEntry
|
||||
) {
|
||||
const { provider, queries } = tenantContext;
|
||||
const { updateUserById } = queries.users;
|
||||
const { provider, libraries } = tenantContext;
|
||||
const { updateUserById } = libraries.users;
|
||||
const { event, profile } = interaction;
|
||||
|
||||
if (event === InteractionEvent.Register) {
|
||||
|
|
|
@ -76,6 +76,11 @@ const tenantContext = new MockTenant(
|
|||
},
|
||||
users: {
|
||||
findUserById: jest.fn().mockResolvedValue(mockUserWithMfaVerifications),
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
users: {
|
||||
updateUserById,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
|
|||
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
|
||||
tenant: TenantContext
|
||||
) {
|
||||
const { provider, queries } = tenant;
|
||||
const { provider, queries, libraries } = tenant;
|
||||
|
||||
// Set New MFA
|
||||
router.post(
|
||||
|
@ -131,7 +131,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
|
|||
|
||||
// Update last used time
|
||||
const user = await queries.users.findUserById(accountId);
|
||||
await queries.users.updateUserById(accountId, {
|
||||
await libraries.users.updateUserById(accountId, {
|
||||
mfaVerifications: user.mfaVerifications.map((mfa) => {
|
||||
if (mfa.id !== verifiedMfa.id) {
|
||||
return mfa;
|
||||
|
|
|
@ -80,7 +80,6 @@ describe('Single sign on util methods tests', () => {
|
|||
insert: insertUserSsoIdentityMock,
|
||||
},
|
||||
users: {
|
||||
updateUserById: updateUserMock,
|
||||
findUserByEmail: findUserByEmailMock,
|
||||
},
|
||||
},
|
||||
|
@ -89,6 +88,7 @@ describe('Single sign on util methods tests', () => {
|
|||
users: {
|
||||
insertUser: insertUserMock,
|
||||
generateUserId: generateUserIdMock,
|
||||
updateUserById: updateUserMock,
|
||||
},
|
||||
ssoConnectors: {
|
||||
getAvailableSsoConnectors: getAvailableSsoConnectorsMock,
|
||||
|
|
|
@ -14,6 +14,7 @@ import RequestError from '#src/errors/RequestError/index.js';
|
|||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
import { type WithInteractionDetailsContext } from '#src/routes/interaction/middleware/koa-interaction-details.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 TenantContext from '#src/tenants/TenantContext.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
@ -139,7 +140,7 @@ export const handleSsoAuthentication = async (
|
|||
ssoAuthentication: SsoAuthenticationResult
|
||||
): Promise<string> => {
|
||||
const { createLog } = ctx;
|
||||
const { provider, queries } = tenant;
|
||||
const { provider, queries, libraries } = tenant;
|
||||
const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = queries;
|
||||
const { issuer, userInfo } = ssoAuthentication;
|
||||
|
||||
|
@ -151,7 +152,7 @@ export const handleSsoAuthentication = async (
|
|||
|
||||
// SignIn
|
||||
if (userSsoIdentity) {
|
||||
return signInWithSsoAuthentication(ctx, queries, {
|
||||
return signInWithSsoAuthentication(ctx, queries, libraries, {
|
||||
connectorData,
|
||||
userSsoIdentity,
|
||||
ssoAuthentication,
|
||||
|
@ -162,7 +163,7 @@ export const handleSsoAuthentication = async (
|
|||
|
||||
// SignIn and link with existing user account with a same email
|
||||
if (user) {
|
||||
return signInAndLinkWithSsoAuthentication(ctx, queries, {
|
||||
return signInAndLinkWithSsoAuthentication(ctx, queries, libraries, {
|
||||
connectorData,
|
||||
user,
|
||||
ssoAuthentication,
|
||||
|
@ -180,7 +181,8 @@ export const handleSsoAuthentication = async (
|
|||
|
||||
const signInWithSsoAuthentication = async (
|
||||
ctx: WithLogContext,
|
||||
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries,
|
||||
{ userSsoIdentities: userSsoIdentitiesQueries }: Queries,
|
||||
{ users: usersLibraries }: Libraries,
|
||||
{
|
||||
connectorData: { id: connectorId, syncProfile },
|
||||
userSsoIdentity: { id, userId },
|
||||
|
@ -211,7 +213,7 @@ const signInWithSsoAuthentication = async (
|
|||
}
|
||||
: undefined;
|
||||
|
||||
await usersQueries.updateUserById(userId, {
|
||||
await usersLibraries.updateUserById(userId, {
|
||||
...syncingProfile,
|
||||
lastSignInAt: Date.now(),
|
||||
});
|
||||
|
@ -234,7 +236,8 @@ const signInWithSsoAuthentication = async (
|
|||
|
||||
const signInAndLinkWithSsoAuthentication = async (
|
||||
ctx: WithLogContext,
|
||||
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries,
|
||||
{ userSsoIdentities: userSsoIdentitiesQueries }: Queries,
|
||||
{ users: usersLibraries }: Libraries,
|
||||
{
|
||||
connectorData: { id: connectorId, syncProfile },
|
||||
user: { id: userId },
|
||||
|
@ -269,7 +272,7 @@ const signInAndLinkWithSsoAuthentication = async (
|
|||
}
|
||||
: undefined;
|
||||
|
||||
await usersQueries.updateUserById(userId, {
|
||||
await usersLibraries.updateUserById(userId, {
|
||||
...syncingProfile,
|
||||
lastSignInAt: Date.now(),
|
||||
});
|
||||
|
|
|
@ -24,12 +24,20 @@ const { mockEsm } = createMockUtils(jest);
|
|||
const findUserById = jest.fn();
|
||||
const updateUserById = jest.fn();
|
||||
|
||||
const tenantContext = new MockTenant(undefined, {
|
||||
users: {
|
||||
findUserById,
|
||||
updateUserById,
|
||||
const tenantContext = new MockTenant(
|
||||
undefined,
|
||||
{
|
||||
users: {
|
||||
findUserById,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
{
|
||||
users: {
|
||||
updateUserById,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({
|
||||
validateTotpToken: jest.fn().mockReturnValue(true),
|
||||
|
|
|
@ -230,7 +230,7 @@ export async function verifyMfaPayloadVerification(
|
|||
|
||||
if (newCounter !== undefined) {
|
||||
// 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) => {
|
||||
if (mfa.type !== MfaFactor.WebAuthn || mfa.id !== result.id) {
|
||||
return mfa;
|
||||
|
@ -250,7 +250,7 @@ export async function verifyMfaPayloadVerification(
|
|||
const { id, type } = await verifyBackupCode(user.mfaVerifications, verifyMfaPayload);
|
||||
|
||||
// Mark the backup code as used
|
||||
await tenant.queries.users.updateUserById(accountId, {
|
||||
await tenant.libraries.users.updateUserById(accountId, {
|
||||
mfaVerifications: user.mfaVerifications.map((mfa) => {
|
||||
if (mfa.id !== id || mfa.type !== MfaFactor.BackupCode) {
|
||||
return mfa;
|
||||
|
|
|
@ -28,12 +28,20 @@ const { mockEsmWithActual } = createMockUtils(jest);
|
|||
const findUserById = jest.fn();
|
||||
const updateUserById = jest.fn();
|
||||
|
||||
const tenantContext = new MockTenant(undefined, {
|
||||
users: {
|
||||
findUserById,
|
||||
updateUserById,
|
||||
const tenantContext = new MockTenant(
|
||||
undefined,
|
||||
{
|
||||
users: {
|
||||
findUserById,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
{
|
||||
users: {
|
||||
updateUserById,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const mockBackupCodes = ['foo'];
|
||||
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({
|
||||
|
|
|
@ -28,12 +28,18 @@ const { mockEsmWithActual } = createMockUtils(jest);
|
|||
const findUserById = jest.fn();
|
||||
const updateUserById = jest.fn();
|
||||
|
||||
const tenantContext = new MockTenant(undefined, {
|
||||
users: {
|
||||
findUserById,
|
||||
updateUserById,
|
||||
const tenantContext = new MockTenant(
|
||||
undefined,
|
||||
{
|
||||
users: {
|
||||
findUserById,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
{
|
||||
users: { updateUserById },
|
||||
}
|
||||
);
|
||||
|
||||
const mockBackupCodes = ['foo'];
|
||||
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
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 = (
|
||||
mfaVerifications: User['mfaVerifications']
|
||||
|
@ -21,3 +25,25 @@ export const transpileUserMfaVerifications = (
|
|||
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;
|
||||
}
|
||||
);
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
"@logto/phrases": "workspace:^1.11.0",
|
||||
"@logto/phrases-experience": "workspace:^1.6.1",
|
||||
"@logto/schemas": "workspace:^1.17.0",
|
||||
"@logto/shared": "workspace:^3.1.0",
|
||||
"@parcel/compressor-brotli": "2.9.3",
|
||||
"@parcel/compressor-gzip": "2.9.3",
|
||||
"@parcel/core": "2.9.3",
|
||||
|
@ -67,7 +68,7 @@
|
|||
"jest-transformer-svg": "^2.0.0",
|
||||
"js-base64": "^3.7.5",
|
||||
"ky": "^1.2.3",
|
||||
"libphonenumber-js": "^1.10.51",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
"lint-staged": "^15.0.0",
|
||||
"parcel": "2.9.3",
|
||||
"parcel-resolver-ignore": "^2.1.3",
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import { parseE164PhoneNumberWithError } from '@logto/shared/universal';
|
||||
import i18next from 'i18next';
|
||||
import type { CountryCode, CountryCallingCode, E164Number } from 'libphonenumber-js/mobile';
|
||||
import {
|
||||
getCountries,
|
||||
getCountryCallingCode,
|
||||
parsePhoneNumberWithError,
|
||||
} from 'libphonenumber-js/mobile';
|
||||
import type { CountryCode, CountryCallingCode } from 'libphonenumber-js/mobile';
|
||||
import { getCountries, getCountryCallingCode } from 'libphonenumber-js/mobile';
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumberWithError(parseE164Number(number));
|
||||
const phoneNumber = parseE164PhoneNumberWithError(number);
|
||||
|
||||
return `+${phoneNumber.countryCallingCode} ${phoneNumber.nationalNumber}`;
|
||||
} catch {
|
||||
|
@ -106,7 +95,7 @@ export const formatPhoneNumberWithCountryCallingCode = (number: string) => {
|
|||
|
||||
export const parsePhoneNumber = (value: string) => {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
|
||||
const phoneNumber = parseE164PhoneNumberWithError(value);
|
||||
|
||||
return {
|
||||
countryCallingCode: phoneNumber.countryCallingCode,
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { usernameRegEx, emailRegEx } from '@logto/core-kit';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { parseE164PhoneNumberWithError } from '@logto/shared/universal';
|
||||
import i18next 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 { IdentifierInputType } from '@/components/InputFields/SmartInputField';
|
||||
import { parseE164Number, parsePhoneNumber } from '@/utils/country-code';
|
||||
import { parsePhoneNumber } from '@/utils/country-code';
|
||||
|
||||
const { t } = i18next;
|
||||
|
||||
|
@ -32,7 +33,7 @@ export const validateEmail = (email: string): ErrorType | undefined => {
|
|||
|
||||
export const validatePhone = (value: string): ErrorType | undefined => {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
|
||||
const phoneNumber = parseE164PhoneNumberWithError(value);
|
||||
|
||||
if (!phoneNumber.isValid()) {
|
||||
return 'invalid_phone';
|
||||
|
|
|
@ -35,7 +35,6 @@ describe('admin console user search params', () => {
|
|||
'jerry swift jr jr',
|
||||
];
|
||||
const emailSuffix = ['@gmail.com', '@foo.bar', '@geek.best'];
|
||||
const phonePrefix = ['101', '102', '202'];
|
||||
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
users = await Promise.all(
|
||||
|
@ -47,8 +46,7 @@ describe('admin console user search params', () => {
|
|||
.map((segment) => segment[0]!.toUpperCase() + segment.slice(1))
|
||||
.join(' ');
|
||||
const primaryEmail = username + emailSuffix[index % emailSuffix.length]!;
|
||||
const primaryPhone =
|
||||
phonePrefix[index % phonePrefix.length]! + index.toString().padStart(5, '0');
|
||||
const primaryPhone = '1310805' + index.toString().padStart(4, '0');
|
||||
|
||||
return createUserByAdmin({ username: prefix + username, primaryEmail, primaryPhone, name });
|
||||
})
|
||||
|
@ -74,7 +72,7 @@ describe('admin console user search params', () => {
|
|||
});
|
||||
|
||||
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(
|
||||
|
|
|
@ -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 () => {
|
||||
const [username, primaryEmail, primaryPhone] = [
|
||||
generateUsername(),
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '#src/api/role.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { generateNewUserProfile } from '#src/helpers/user.js';
|
||||
import { generatePhone } from '#src/utils.js';
|
||||
|
||||
describe('roles users', () => {
|
||||
it('should get role users successfully and can get roles correctly (specifying exclude user)', async () => {
|
||||
|
@ -38,7 +39,14 @@ describe('roles users', () => {
|
|||
name: 'user001',
|
||||
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' });
|
||||
await assignUsersToRole([user1.id, user2.id, user3.id], role.id);
|
||||
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
"@silverhand/essentials": "^2.9.1",
|
||||
"chalk": "^5.0.0",
|
||||
"find-up": "^7.0.0",
|
||||
"libphonenumber-js": "^1.9.49",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
"nanoid": "^5.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
@ -6,7 +29,7 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js';
|
|||
*/
|
||||
export const parsePhoneNumber = (phone: string) => {
|
||||
try {
|
||||
return parsePhoneNumberWithError(phone).number.slice(1);
|
||||
return parseE164PhoneNumberWithError(phone).number.slice(1);
|
||||
} catch {
|
||||
console.error(`Invalid phone number: ${phone}`);
|
||||
return phone;
|
||||
|
@ -19,8 +42,7 @@ export const parsePhoneNumber = (phone: string) => {
|
|||
*/
|
||||
export const formatToInternationalPhoneNumber = (phone: string) => {
|
||||
try {
|
||||
const phoneNumber = phone.startsWith('+') ? phone : `+${phone}`;
|
||||
return parsePhoneNumberWithError(phoneNumber).formatInternational();
|
||||
return parseE164PhoneNumberWithError(phone).formatInternational();
|
||||
} catch {
|
||||
console.error(`Invalid phone number: ${phone}`);
|
||||
return phone;
|
||||
|
|
|
@ -3032,8 +3032,8 @@ importers:
|
|||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
libphonenumber-js:
|
||||
specifier: ^1.10.51
|
||||
version: 1.10.51
|
||||
specifier: ^1.11.1
|
||||
version: 1.11.1
|
||||
lint-staged:
|
||||
specifier: ^15.0.0
|
||||
version: 15.0.2
|
||||
|
@ -3563,6 +3563,9 @@ importers:
|
|||
'@logto/schemas':
|
||||
specifier: workspace:^1.17.0
|
||||
version: link:../schemas
|
||||
'@logto/shared':
|
||||
specifier: workspace:^3.1.0
|
||||
version: link:../shared
|
||||
'@parcel/compressor-brotli':
|
||||
specifier: 2.9.3
|
||||
version: 2.9.3(@parcel/core@2.9.3)
|
||||
|
@ -3684,8 +3687,8 @@ importers:
|
|||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
libphonenumber-js:
|
||||
specifier: ^1.10.51
|
||||
version: 1.10.51
|
||||
specifier: ^1.11.1
|
||||
version: 1.11.1
|
||||
lint-staged:
|
||||
specifier: ^15.0.0
|
||||
version: 15.0.2
|
||||
|
@ -4012,8 +4015,8 @@ importers:
|
|||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
libphonenumber-js:
|
||||
specifier: ^1.9.49
|
||||
version: 1.10.51
|
||||
specifier: ^1.11.1
|
||||
version: 1.11.1
|
||||
nanoid:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
|
@ -9897,8 +9900,8 @@ packages:
|
|||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
libphonenumber-js@1.10.51:
|
||||
resolution: {integrity: sha512-vY2I+rQwrDQzoPds0JeTEpeWzbUJgqoV0O4v31PauHBb/e+1KCXKylHcDnBMgJZ9fH9mErsEbROJY3Z3JtqEmg==}
|
||||
libphonenumber-js@1.11.1:
|
||||
resolution: {integrity: sha512-Wze1LPwcnzvcKGcRHFGFECTaLzxOtujwpf924difr5zniyYv1C2PiW0419qDR7m8lKDxsImu5mwxFuXhXpjmvw==}
|
||||
|
||||
lightningcss-darwin-arm64@1.16.1:
|
||||
resolution: {integrity: sha512-/J898YSAiGVqdybHdIF3Ao0Hbh2vyVVj5YNm3NznVzTSvkOi3qQCAtO97sfmNz+bSRHXga7ZPLm+89PpOM5gAg==}
|
||||
|
@ -20790,7 +20793,7 @@ snapshots:
|
|||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
libphonenumber-js@1.10.51: {}
|
||||
libphonenumber-js@1.11.1: {}
|
||||
|
||||
lightningcss-darwin-arm64@1.16.1:
|
||||
optional: true
|
||||
|
|
Loading…
Reference in a new issue