0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

refactor(core): add profile verification (#2551)

This commit is contained in:
simeng-li 2022-12-01 09:48:24 +08:00 committed by GitHub
parent 63f9ec57eb
commit e4b007da38
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 764 additions and 16 deletions

View file

@ -7,8 +7,10 @@ import type {
PhonePasscodePayload,
} from '@logto/schemas';
import {
usernamePasswordPayloadGuard,
emailPasscodePayloadGuard,
phonePasscodePayloadGuard,
socialConnectorPayloadGuard,
eventGuard,
profileGuard,
identifierGuard,
@ -20,7 +22,7 @@ import { z } from 'zod';
const forgotPasswordInteractionPayloadGuard = z.object({
event: z.literal(Event.ForgotPassword),
identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(),
profile: profileGuard.pick({ password: true }).optional(),
profile: z.object({ password: z.string() }).optional(),
});
const registerInteractionPayloadGuard = z.object({
@ -51,7 +53,7 @@ export type PasswordIdentifierPayload =
export type PasscodeIdentifierPayload = EmailPasscodePayload | PhonePasscodePayload;
// Passcode Send Route Guard
// Passcode Send Route Payload Guard
export const sendPasscodePayloadGuard = z.union([
z.object({
event: eventGuard,
@ -62,14 +64,24 @@ export const sendPasscodePayloadGuard = z.union([
phone: z.string().regex(phoneRegEx),
}),
]);
export type SendPasscodePayload = z.infer<typeof sendPasscodePayloadGuard>;
// Social Authorization Uri Route Guard
// Social Authorization Uri Route Payload Guard
export const getSocialAuthorizationUrlPayloadGuard = z.object({
connectorId: z.string(),
state: z.string(),
redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')),
});
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,
]);

View file

@ -13,9 +13,9 @@ export type Identifier =
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 };

View file

@ -1,4 +1,4 @@
import type { Profile, SocialConnectorPayload } from '@logto/schemas';
import type { Profile, SocialConnectorPayload, User } from '@logto/schemas';
import type {
PasscodeIdentifierPayload,
@ -32,3 +32,11 @@ export const isProfileIdentifier = (
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;
};

View file

@ -9,5 +9,5 @@ export const assignIdentifierVerificationResult = async (
ctx: Context,
provider: Provider
) => {
await provider.interactionResult(ctx.req, ctx.res, payload);
await provider.interactionResult(ctx.req, ctx.res, payload, { mergeWithLastSubmission: true });
};

View file

@ -150,7 +150,7 @@ describe('identifier verification', () => {
expect(result).toEqual([
{ key: 'accountId', value: 'foo' },
{ key: 'verifiedEmail', value: 'email' },
{ key: 'emailVerified', value: 'email' },
]);
});
@ -178,7 +178,7 @@ describe('identifier verification', () => {
expect(assignIdentifierVerificationResult).toBeCalledWith(
{
event: Event.SignIn,
identifiers: [{ key: 'verifiedEmail', value: 'email' }],
identifiers: [{ key: 'emailVerified', value: 'email' }],
},
ctx,
provider
@ -231,7 +231,7 @@ describe('identifier verification', () => {
);
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 () => {
@ -256,7 +256,7 @@ describe('identifier verification', () => {
expect(result).toEqual([
{ key: 'accountId', value: 'foo' },
{ key: 'verifiedPhone', value: 'phone' },
{ key: 'phoneVerified', value: 'phone' },
]);
});
@ -282,6 +282,6 @@ describe('identifier verification', () => {
);
expect(findUserByIdentifierMock).not.toBeCalled();
expect(result).toEqual([{ key: 'verifiedPhone', value: 'phone' }]);
expect(result).toEqual([{ key: 'phoneVerified', value: 'phone' }]);
});
});

View file

@ -42,8 +42,8 @@ const passcodeIdentifierVerification = async (
const verifiedPasscodeIdentifier: Identifier =
'email' in identifier
? { key: 'verifiedEmail', value: identifier.email }
: { key: 'verifiedPhone', value: identifier.phone };
? { key: 'emailVerified', value: identifier.email }
: { key: 'phoneVerified', value: identifier.phone };
// Return the verified identity directly if it is for new profile identity verification
if (isProfileIdentifier(identifier, profile)) {

View file

@ -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',
})
);
});
});

View file

@ -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,
})
);
});
});

View file

@ -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();
});
});
});

View file

@ -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 })
);
}