mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
refactor(core): add profile verification (#2551)
This commit is contained in:
parent
63f9ec57eb
commit
e4b007da38
10 changed files with 764 additions and 16 deletions
|
@ -7,8 +7,10 @@ import type {
|
||||||
PhonePasscodePayload,
|
PhonePasscodePayload,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import {
|
import {
|
||||||
|
usernamePasswordPayloadGuard,
|
||||||
emailPasscodePayloadGuard,
|
emailPasscodePayloadGuard,
|
||||||
phonePasscodePayloadGuard,
|
phonePasscodePayloadGuard,
|
||||||
|
socialConnectorPayloadGuard,
|
||||||
eventGuard,
|
eventGuard,
|
||||||
profileGuard,
|
profileGuard,
|
||||||
identifierGuard,
|
identifierGuard,
|
||||||
|
@ -20,7 +22,7 @@ import { z } from 'zod';
|
||||||
const forgotPasswordInteractionPayloadGuard = z.object({
|
const forgotPasswordInteractionPayloadGuard = z.object({
|
||||||
event: z.literal(Event.ForgotPassword),
|
event: z.literal(Event.ForgotPassword),
|
||||||
identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(),
|
identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(),
|
||||||
profile: profileGuard.pick({ password: true }).optional(),
|
profile: z.object({ password: z.string() }).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const registerInteractionPayloadGuard = z.object({
|
const registerInteractionPayloadGuard = z.object({
|
||||||
|
@ -51,7 +53,7 @@ export type PasswordIdentifierPayload =
|
||||||
|
|
||||||
export type PasscodeIdentifierPayload = EmailPasscodePayload | PhonePasscodePayload;
|
export type PasscodeIdentifierPayload = EmailPasscodePayload | PhonePasscodePayload;
|
||||||
|
|
||||||
// Passcode Send Route Guard
|
// Passcode Send Route Payload Guard
|
||||||
export const sendPasscodePayloadGuard = z.union([
|
export const sendPasscodePayloadGuard = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
event: eventGuard,
|
event: eventGuard,
|
||||||
|
@ -62,14 +64,24 @@ export const sendPasscodePayloadGuard = z.union([
|
||||||
phone: z.string().regex(phoneRegEx),
|
phone: z.string().regex(phoneRegEx),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SendPasscodePayload = z.infer<typeof sendPasscodePayloadGuard>;
|
export type SendPasscodePayload = z.infer<typeof sendPasscodePayloadGuard>;
|
||||||
|
|
||||||
// Social Authorization Uri Route Guard
|
// Social Authorization Uri Route Payload Guard
|
||||||
export const getSocialAuthorizationUrlPayloadGuard = z.object({
|
export const getSocialAuthorizationUrlPayloadGuard = z.object({
|
||||||
connectorId: z.string(),
|
connectorId: z.string(),
|
||||||
state: z.string(),
|
state: z.string(),
|
||||||
redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')),
|
redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SocialAuthorizationUrlPayload = z.infer<typeof getSocialAuthorizationUrlPayloadGuard>;
|
export type SocialAuthorizationUrlPayload = z.infer<typeof getSocialAuthorizationUrlPayloadGuard>;
|
||||||
|
|
||||||
|
// Register Profile Guard
|
||||||
|
const emailProfileGuard = emailPasscodePayloadGuard.pick({ email: true });
|
||||||
|
const phoneProfileGuard = phonePasscodePayloadGuard.pick({ phone: true });
|
||||||
|
const socialProfileGuard = socialConnectorPayloadGuard.pick({ connectorId: true });
|
||||||
|
|
||||||
|
export const registerProfileSafeGuard = z.union([
|
||||||
|
usernamePasswordPayloadGuard,
|
||||||
|
emailProfileGuard,
|
||||||
|
phoneProfileGuard,
|
||||||
|
socialProfileGuard,
|
||||||
|
]);
|
||||||
|
|
|
@ -13,9 +13,9 @@ export type Identifier =
|
||||||
|
|
||||||
export type AccountIdIdentifier = { key: 'accountId'; value: string };
|
export type AccountIdIdentifier = { key: 'accountId'; value: string };
|
||||||
|
|
||||||
export type VerifiedEmailIdentifier = { key: 'verifiedEmail'; value: string };
|
export type VerifiedEmailIdentifier = { key: 'emailVerified'; value: string };
|
||||||
|
|
||||||
export type VerifiedPhoneIdentifier = { key: 'verifiedPhone'; value: string };
|
export type VerifiedPhoneIdentifier = { key: 'phoneVerified'; value: string };
|
||||||
|
|
||||||
export type SocialIdentifier = { key: 'social'; connectorId: string; value: UseInfo };
|
export type SocialIdentifier = { key: 'social'; connectorId: string; value: UseInfo };
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Profile, SocialConnectorPayload } from '@logto/schemas';
|
import type { Profile, SocialConnectorPayload, User } from '@logto/schemas';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
PasscodeIdentifierPayload,
|
PasscodeIdentifierPayload,
|
||||||
|
@ -32,3 +32,11 @@ export const isProfileIdentifier = (
|
||||||
|
|
||||||
return profile?.connectorId === identifier.connectorId;
|
return profile?.connectorId === identifier.connectorId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Social identities can take place the role of password
|
||||||
|
export const isUserPasswordSet = ({
|
||||||
|
passwordEncrypted,
|
||||||
|
identities,
|
||||||
|
}: Pick<User, 'passwordEncrypted' | 'identities'>): boolean => {
|
||||||
|
return Boolean(passwordEncrypted) || Object.keys(identities).length > 0;
|
||||||
|
};
|
||||||
|
|
|
@ -9,5 +9,5 @@ export const assignIdentifierVerificationResult = async (
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
provider: Provider
|
provider: Provider
|
||||||
) => {
|
) => {
|
||||||
await provider.interactionResult(ctx.req, ctx.res, payload);
|
await provider.interactionResult(ctx.req, ctx.res, payload, { mergeWithLastSubmission: true });
|
||||||
};
|
};
|
||||||
|
|
|
@ -150,7 +150,7 @@ describe('identifier verification', () => {
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{ key: 'accountId', value: 'foo' },
|
{ key: 'accountId', value: 'foo' },
|
||||||
{ key: 'verifiedEmail', value: 'email' },
|
{ key: 'emailVerified', value: 'email' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -178,7 +178,7 @@ describe('identifier verification', () => {
|
||||||
expect(assignIdentifierVerificationResult).toBeCalledWith(
|
expect(assignIdentifierVerificationResult).toBeCalledWith(
|
||||||
{
|
{
|
||||||
event: Event.SignIn,
|
event: Event.SignIn,
|
||||||
identifiers: [{ key: 'verifiedEmail', value: 'email' }],
|
identifiers: [{ key: 'emailVerified', value: 'email' }],
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
provider
|
provider
|
||||||
|
@ -231,7 +231,7 @@ describe('identifier verification', () => {
|
||||||
);
|
);
|
||||||
expect(findUserByIdentifierMock).not.toBeCalled();
|
expect(findUserByIdentifierMock).not.toBeCalled();
|
||||||
|
|
||||||
expect(result).toEqual([{ key: 'verifiedEmail', value: 'email' }]);
|
expect(result).toEqual([{ key: 'emailVerified', value: 'email' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('phone passcode with no profile', async () => {
|
it('phone passcode with no profile', async () => {
|
||||||
|
@ -256,7 +256,7 @@ describe('identifier verification', () => {
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{ key: 'accountId', value: 'foo' },
|
{ key: 'accountId', value: 'foo' },
|
||||||
{ key: 'verifiedPhone', value: 'phone' },
|
{ key: 'phoneVerified', value: 'phone' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -282,6 +282,6 @@ describe('identifier verification', () => {
|
||||||
);
|
);
|
||||||
expect(findUserByIdentifierMock).not.toBeCalled();
|
expect(findUserByIdentifierMock).not.toBeCalled();
|
||||||
|
|
||||||
expect(result).toEqual([{ key: 'verifiedPhone', value: 'phone' }]);
|
expect(result).toEqual([{ key: 'phoneVerified', value: 'phone' }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -42,8 +42,8 @@ const passcodeIdentifierVerification = async (
|
||||||
|
|
||||||
const verifiedPasscodeIdentifier: Identifier =
|
const verifiedPasscodeIdentifier: Identifier =
|
||||||
'email' in identifier
|
'email' in identifier
|
||||||
? { key: 'verifiedEmail', value: identifier.email }
|
? { key: 'emailVerified', value: identifier.email }
|
||||||
: { key: 'verifiedPhone', value: identifier.phone };
|
: { key: 'phoneVerified', value: identifier.phone };
|
||||||
|
|
||||||
// Return the verified identity directly if it is for new profile identity verification
|
// Return the verified identity directly if it is for new profile identity verification
|
||||||
if (isProfileIdentifier(identifier, profile)) {
|
if (isProfileIdentifier(identifier, profile)) {
|
||||||
|
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { Event } from '@logto/schemas';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import { findUserById } from '#src/queries/user.js';
|
||||||
|
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import type { Identifier, InteractionContext } from '../types/index.js';
|
||||||
|
import profileVerification from './profile-verification.js';
|
||||||
|
|
||||||
|
jest.mock('#src/queries/user.js', () => ({
|
||||||
|
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
|
||||||
|
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
||||||
|
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
||||||
|
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../utils/index.js', () => ({
|
||||||
|
isUserPasswordSet: jest.fn().mockResolvedValueOnce(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Existed profile should throw', () => {
|
||||||
|
const baseCtx = createContextWithRouteParameters();
|
||||||
|
const identifiers: Identifier[] = [
|
||||||
|
{ key: 'accountId', value: 'foo' },
|
||||||
|
{ key: 'emailVerified', value: 'email' },
|
||||||
|
{ key: 'phoneVerified', value: 'phone' },
|
||||||
|
{ key: 'social', connectorId: 'connectorId', value: { id: 'foo' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('username exist', async () => {
|
||||||
|
(findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo', username: 'foo' });
|
||||||
|
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
username: 'username',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.username_exists',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('email exist', async () => {
|
||||||
|
(findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo', primaryEmail: 'email' });
|
||||||
|
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
email: 'email',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.email_exists',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phone exist', async () => {
|
||||||
|
(findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo', primaryPhone: 'phone' });
|
||||||
|
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
phone: 'phone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.sms_exists',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('password exist', async () => {
|
||||||
|
(findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo' });
|
||||||
|
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.password_exists',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { Event } from '@logto/schemas';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import {
|
||||||
|
hasUser,
|
||||||
|
hasUserWithEmail,
|
||||||
|
hasUserWithPhone,
|
||||||
|
hasUserWithIdentity,
|
||||||
|
} from '#src/queries/user.js';
|
||||||
|
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import type { Identifier, InteractionContext } from '../types/index.js';
|
||||||
|
import profileVerification from './profile-verification.js';
|
||||||
|
|
||||||
|
jest.mock('#src/queries/user.js', () => ({
|
||||||
|
hasUser: jest.fn().mockResolvedValue(false),
|
||||||
|
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
|
||||||
|
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
||||||
|
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
||||||
|
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('#src/connectors/index.js', () => ({
|
||||||
|
getLogtoConnectorById: jest.fn().mockResolvedValue({
|
||||||
|
metadata: { target: 'logto' },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseCtx = createContextWithRouteParameters();
|
||||||
|
const identifiers: Identifier[] = [
|
||||||
|
{ key: 'accountId', value: 'foo' },
|
||||||
|
{ key: 'emailVerified', value: 'email@logto.io' },
|
||||||
|
{ key: 'phoneVerified', value: '123456' },
|
||||||
|
{ key: 'social', connectorId: 'connectorId', value: { id: 'foo' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('register payload guard', () => {
|
||||||
|
it('username only should throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.Register,
|
||||||
|
profile: {
|
||||||
|
username: 'username',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('password only should throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.Register,
|
||||||
|
profile: {
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('username password is valid', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.Register,
|
||||||
|
profile: {
|
||||||
|
username: 'username',
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('username with a given email is valid', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.Register,
|
||||||
|
profile: {
|
||||||
|
username: 'username',
|
||||||
|
email: 'email@logto.io',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('password with a given email is valid', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.Register,
|
||||||
|
profile: {
|
||||||
|
password: 'password',
|
||||||
|
email: 'email@logto.io',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('profile registered validation', () => {
|
||||||
|
it('username is registered', async () => {
|
||||||
|
(hasUser as jest.Mock).mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.Register,
|
||||||
|
profile: {
|
||||||
|
username: 'username',
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.username_exists_register',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('email is registered', async () => {
|
||||||
|
(hasUserWithEmail as jest.Mock).mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.Register,
|
||||||
|
profile: {
|
||||||
|
email: 'email@logto.io',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.email_exists_register',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phone is registered', async () => {
|
||||||
|
(hasUserWithPhone as jest.Mock).mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.Register,
|
||||||
|
profile: {
|
||||||
|
phone: '123456',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.phone_exists_register',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connector identity exist', async () => {
|
||||||
|
(hasUserWithIdentity as jest.Mock).mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.Register,
|
||||||
|
profile: {
|
||||||
|
connectorId: 'connectorId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.identity_exists',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { Event } from '@logto/schemas';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import type { Identifier, InteractionContext } from '../types/index.js';
|
||||||
|
import profileVerification from './profile-verification.js';
|
||||||
|
|
||||||
|
jest.mock('#src/queries/user.js', () => ({
|
||||||
|
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
|
||||||
|
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
||||||
|
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
||||||
|
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('#src/connectors/index.js', () => ({
|
||||||
|
getLogtoConnectorById: jest.fn().mockResolvedValue({
|
||||||
|
metadata: { target: 'logto' },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('profile protected identifier verification', () => {
|
||||||
|
const baseCtx = createContextWithRouteParameters();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email, phone and social identifier must be verified', () => {
|
||||||
|
it('email without a verified identifier should throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
email: 'email',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }];
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('email with unmatched identifier should throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
email: 'email',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers: Identifier[] = [
|
||||||
|
{ key: 'accountId', value: 'foo' },
|
||||||
|
{ key: 'emailVerified', value: 'phone' },
|
||||||
|
];
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('email with proper identifier should not throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
email: 'email',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers: Identifier[] = [
|
||||||
|
{ key: 'accountId', value: 'foo' },
|
||||||
|
{ key: 'emailVerified', value: 'email' },
|
||||||
|
];
|
||||||
|
await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phone without a verified identifier should throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
phone: 'phone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }];
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phone with unmatched identifier should throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
phone: 'phone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers: Identifier[] = [
|
||||||
|
{ key: 'accountId', value: 'foo' },
|
||||||
|
{ key: 'phoneVerified', value: 'email' },
|
||||||
|
];
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phone with proper identifier should not throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
phone: 'phone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers: Identifier[] = [
|
||||||
|
{ key: 'accountId', value: 'foo' },
|
||||||
|
{ key: 'phoneVerified', value: 'phone' },
|
||||||
|
];
|
||||||
|
await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connectorId without a verified identifier should throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
connectorId: 'connectorId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }];
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({ code: 'session.connector_session_not_found', status: 404 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connectorId with unmatched identifier should throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
connectorId: 'logto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers: Identifier[] = [
|
||||||
|
{ key: 'accountId', value: 'foo' },
|
||||||
|
{ key: 'social', connectorId: 'connectorId', value: { id: 'foo' } },
|
||||||
|
];
|
||||||
|
await expect(profileVerification(ctx, identifiers)).rejects.toMatchError(
|
||||||
|
new RequestError({ code: 'session.connector_session_not_found', status: 404 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connectorId with proper identifier should not throw', async () => {
|
||||||
|
const ctx: InteractionContext = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: {
|
||||||
|
event: Event.SignIn,
|
||||||
|
profile: {
|
||||||
|
connectorId: 'logto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers: Identifier[] = [
|
||||||
|
{ key: 'accountId', value: 'foo' },
|
||||||
|
{ key: 'social', connectorId: 'logto', value: { id: 'foo' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,219 @@
|
||||||
|
import type { Profile, User } from '@logto/schemas';
|
||||||
|
import { Event } from '@logto/schemas';
|
||||||
|
import { argon2Verify } from 'hash-wasm';
|
||||||
|
|
||||||
|
import { getLogtoConnectorById } from '#src/connectors/index.js';
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import {
|
||||||
|
findUserById,
|
||||||
|
hasUser,
|
||||||
|
hasUserWithEmail,
|
||||||
|
hasUserWithPhone,
|
||||||
|
hasUserWithIdentity,
|
||||||
|
} from '#src/queries/user.js';
|
||||||
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
|
import { registerProfileSafeGuard } from '../types/guard.js';
|
||||||
|
import type {
|
||||||
|
InteractionContext,
|
||||||
|
Identifier,
|
||||||
|
AccountIdIdentifier,
|
||||||
|
SocialIdentifier,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { isUserPasswordSet } from '../utils/index.js';
|
||||||
|
|
||||||
|
const findUserByIdentifier = async (identifiers: Identifier[]) => {
|
||||||
|
const accountIdentifier = identifiers.find(
|
||||||
|
(identifier): identifier is AccountIdIdentifier => identifier.key === 'accountId'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
accountIdentifier,
|
||||||
|
new RequestError({
|
||||||
|
code: 'session.unauthorized',
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return findUserById(accountIdentifier.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyProtectedIdentifiers = (
|
||||||
|
{ email, phone, connectorId }: Profile,
|
||||||
|
identifiers: Identifier[]
|
||||||
|
) => {
|
||||||
|
if (email) {
|
||||||
|
assertThat(
|
||||||
|
identifiers.some(({ key, value }) => key === 'emailVerified' && value === email),
|
||||||
|
new RequestError({
|
||||||
|
code: 'session.verification_session_not_found',
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phone) {
|
||||||
|
assertThat(
|
||||||
|
identifiers.some(({ key, value }) => key === 'phoneVerified' && value === phone),
|
||||||
|
new RequestError({
|
||||||
|
code: 'session.verification_session_not_found',
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectorId) {
|
||||||
|
assertThat(
|
||||||
|
identifiers.some(
|
||||||
|
(identifier) => identifier.key === 'social' && identifier.connectorId === connectorId
|
||||||
|
),
|
||||||
|
new RequestError({
|
||||||
|
code: 'session.connector_session_not_found',
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileRegisteredValidation = async (
|
||||||
|
{ username, email, phone, connectorId }: Profile,
|
||||||
|
identifiers: Identifier[]
|
||||||
|
) => {
|
||||||
|
if (username) {
|
||||||
|
assertThat(
|
||||||
|
!(await hasUser(username)),
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.username_exists_register',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
assertThat(
|
||||||
|
!(await hasUserWithEmail(email)),
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.email_exists_register',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phone) {
|
||||||
|
assertThat(
|
||||||
|
!(await hasUserWithPhone(phone)),
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.phone_exists_register',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectorId) {
|
||||||
|
const {
|
||||||
|
metadata: { target },
|
||||||
|
} = await getLogtoConnectorById(connectorId);
|
||||||
|
|
||||||
|
const socialIdentifier = identifiers.find(
|
||||||
|
(identifier): identifier is SocialIdentifier => identifier.key === 'social'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Social identifier session should be verified by verifyProtectedIdentifiers
|
||||||
|
if (!socialIdentifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
!(await hasUserWithIdentity(target, socialIdentifier.value.id)),
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.identity_exists',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileExistValidation = async (
|
||||||
|
{ username, email, phone, password }: Profile,
|
||||||
|
user: User
|
||||||
|
) => {
|
||||||
|
if (username) {
|
||||||
|
assertThat(
|
||||||
|
!user.username,
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.username_exists',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
assertThat(
|
||||||
|
!user.primaryEmail,
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.email_exists',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phone) {
|
||||||
|
assertThat(
|
||||||
|
!user.primaryPhone,
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.sms_exists',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
assertThat(
|
||||||
|
!isUserPasswordSet(user),
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.password_exists',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function profileVerification(
|
||||||
|
ctx: InteractionContext,
|
||||||
|
identifiers: Identifier[]
|
||||||
|
) {
|
||||||
|
const { profile, event } = ctx.interactionPayload;
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyProtectedIdentifiers(profile, identifiers);
|
||||||
|
|
||||||
|
if (event === Event.SignIn) {
|
||||||
|
// Find existing account
|
||||||
|
const user = await findUserByIdentifier(identifiers);
|
||||||
|
|
||||||
|
await profileExistValidation(profile, user);
|
||||||
|
await profileRegisteredValidation(profile, identifiers);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === Event.Register) {
|
||||||
|
// Verify the profile includes sufficient identifiers to register a new account
|
||||||
|
try {
|
||||||
|
registerProfileSafeGuard.parse(profile);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new RequestError({ code: 'guard.invalid_input' }, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await profileRegisteredValidation(profile, identifiers);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForgotPassword
|
||||||
|
const { password } = profile;
|
||||||
|
const { passwordEncrypted: oldPasswordEncrypted } = await findUserByIdentifier(identifiers);
|
||||||
|
assertThat(
|
||||||
|
!oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })),
|
||||||
|
new RequestError({ code: 'user.same_password', status: 422 })
|
||||||
|
);
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue