0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(core): update the interaction guard (#2521)

This commit is contained in:
simeng-li 2022-11-24 17:15:16 +08:00 committed by GitHub
parent 296f5f357a
commit 6b909f033f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 713 additions and 290 deletions

View file

@ -99,7 +99,7 @@
"openapi-types": "^12.0.0",
"prettier": "^2.7.1",
"supertest": "^6.2.2",
"typescript": "^4.7.4"
"typescript": "^4.9.3"
},
"engines": {
"node": "^16.13.0 || ^18.12.0"

View file

@ -56,7 +56,7 @@ describe('koaInteractionBodyGuard', () => {
});
describe('identifier', () => {
it('invalid identifier should not parsed', async () => {
it('invalid identifier should throw', async () => {
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
...baseCtx,
request: {
@ -64,15 +64,15 @@ describe('koaInteractionBodyGuard', () => {
body: {
event: 'sign-in',
identifier: {
google: 'username',
username: 'username',
passcode: 'passcode',
},
},
},
interactionPayload: {},
};
await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow();
expect(ctx.interactionPayload.identifier).not.toContain({ google: 'username' });
await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow();
});
it.each(interactionMocks)('interaction methods should parse successfully', async (input) => {

View file

@ -1,48 +1,40 @@
import type { SignInExperience } from '@logto/schemas';
import { SignInMode } from '@logto/schemas';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import type { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js';
import assertThat from '#src/utils/assert-that.js';
import {
signInModeValidation,
identifierValidation,
profileValidation,
} from '../utils/sign-in-experience-validation.js';
import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body-guard.js';
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
export type WithSignInExperienceContext<
ContextT extends WithGuardedIdentifierPayloadContext<IRouterParamContext>
> = ContextT & {
signInExperience: SignInExperience;
};
export default function koaSessionSignInExperienceGuard<
StateT,
ContextT extends WithGuardedIdentifierPayloadContext<IRouterParamContext>,
ResponseBodyT
>(
provider: Provider
): MiddlewareType<StateT, WithSignInExperienceContext<ContextT>, ResponseBodyT> {
>(provider: Provider): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
const { event } = ctx.interactionPayload;
const { event, identifier, profile } = ctx.interactionPayload;
const signInExperience = await getSignInExperienceForApplication(
typeof interaction.params.client_id === 'string' ? interaction.params.client_id : undefined
);
// SignInMode validation
if (event === 'sign-in') {
assertThat(signInExperience.signInMode !== SignInMode.Register, forbiddenEventError);
if (event) {
signInModeValidation(event, signInExperience);
}
if (event === 'register') {
assertThat(signInExperience.signInMode !== SignInMode.SignIn, forbiddenEventError);
if (identifier) {
identifierValidation(identifier, signInExperience);
}
ctx.signInExperience = signInExperience;
if (profile) {
profileValidation(profile, signInExperience);
}
return next();
};

View file

@ -1,14 +1,23 @@
import { SignInMode } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import {
signInModeValidation,
identifierValidation,
profileValidation,
} from '../utils/sign-in-experience-validation.js';
import koaSessionSignInExperienceGuard from './koa-session-sign-in-experience-guard.js';
jest.mock('#src/lib/sign-in-experience/index.js', () => ({
getSignInExperienceForApplication: jest.fn(),
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
}));
jest.mock('../utils/sign-in-experience-validation.js', () => ({
signInModeValidation: jest.fn(),
identifierValidation: jest.fn(),
profileValidation: jest.fn(),
}));
jest.mock('oidc-provider', () => ({
@ -18,62 +27,26 @@ jest.mock('oidc-provider', () => ({
}));
describe('koaSessionSignInExperienceGuard', () => {
const getSignInExperienceForApplicationMock = getSignInExperienceForApplication as jest.Mock;
const baseCtx = createContextWithRouteParameters();
const next = jest.fn();
describe('sign-in mode guard', () => {
it('should reject register', async () => {
getSignInExperienceForApplicationMock.mockImplementationOnce(() => ({
signInMode: SignInMode.SignIn,
}));
it('should call validation method properly', async () => {
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'register',
identifier: { username: 'username', password: 'password' },
profile: { email: 'email' },
}),
};
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'register',
}),
signInExperience: mockSignInExperience,
};
await koaSessionSignInExperienceGuard(new Provider(''))(ctx, next);
await expect(koaSessionSignInExperienceGuard(new Provider(''))(ctx, next)).rejects.toThrow();
});
it('should reject sign-in', async () => {
getSignInExperienceForApplicationMock.mockImplementationOnce(() => ({
signInMode: SignInMode.Register,
}));
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'sign-in',
}),
signInExperience: mockSignInExperience,
};
await expect(koaSessionSignInExperienceGuard(new Provider(''))(ctx, next)).rejects.toThrow();
});
it('should allow register', async () => {
getSignInExperienceForApplicationMock.mockImplementationOnce(() => ({
signInMode: SignInMode.SignInAndRegister,
}));
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'register',
}),
signInExperience: mockSignInExperience,
};
await expect(
koaSessionSignInExperienceGuard(new Provider(''))(ctx, next)
).resolves.not.toThrow();
expect(ctx.signInExperience).toEqual({
signInMode: SignInMode.SignInAndRegister,
});
});
expect(signInModeValidation).toBeCalledWith('register', mockSignInExperience);
expect(identifierValidation).toBeCalledWith(
{ username: 'username', password: 'password' },
mockSignInExperience
);
expect(profileValidation).toBeCalledWith({ email: 'email' }, mockSignInExperience);
});
});

View file

@ -1,22 +1,4 @@
import type {
UsernamePasswordPayload,
EmailPasscodePayload,
PhonePasswordPayload,
EmailPasswordPayload,
PhonePasscodePayload,
SocialConnectorPayload,
} from '@logto/schemas';
import {
eventGuard,
profileGuard,
identifierGuard,
usernamePasswordPayloadGuard,
emailPasswordPayloadGuard,
phonePasswordPayloadGuard,
emailPasscodePayloadGuard,
phonePasscodePayloadGuard,
socialConnectorPayloadGuard,
} from '@logto/schemas';
import { eventGuard, profileGuard, identifierGuard } from '@logto/schemas';
import { z } from 'zod';
export const interactionPayloadGuard = z.object({
@ -26,29 +8,4 @@ export const interactionPayloadGuard = z.object({
});
export type InteractionPayload = z.infer<typeof interactionPayloadGuard>;
export const isUsernamePassword = (
identifier: InteractionPayload['identifier']
): identifier is UsernamePasswordPayload =>
usernamePasswordPayloadGuard.safeParse(identifier).success;
export const isEmailPassword = (
identifier: InteractionPayload['identifier']
): identifier is EmailPasswordPayload => emailPasswordPayloadGuard.safeParse(identifier).success;
export const isPhonePassword = (
identifier: InteractionPayload['identifier']
): identifier is PhonePasswordPayload => phonePasswordPayloadGuard.safeParse(identifier).success;
export const isEmailPasscode = (
identifier: InteractionPayload['identifier']
): identifier is EmailPasscodePayload => emailPasscodePayloadGuard.safeParse(identifier).success;
export const isPhonePasscode = (
identifier: InteractionPayload['identifier']
): identifier is PhonePasscodePayload => phonePasscodePayloadGuard.safeParse(identifier).success;
export const isSocialConnector = (
identifier: InteractionPayload['identifier']
): identifier is SocialConnectorPayload =>
socialConnectorPayloadGuard.safeParse(identifier).success;
export type IdentifierPayload = z.infer<typeof identifierGuard>;

View file

@ -2,7 +2,6 @@ import type { Context } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import type { WithGuardedIdentifierPayloadContext } from '../middleware/koa-interaction-body-guard.js';
import type { WithSignInExperienceContext } from '../middleware/koa-session-sign-in-experience-guard.js';
export type Identifier =
| AccountIdIdentifier
@ -26,6 +25,4 @@ type UseInfo = {
id: string;
};
export type InteractionContext = WithSignInExperienceContext<
WithGuardedIdentifierPayloadContext<IRouterParamContext & Context>
>;
export type InteractionContext = WithGuardedIdentifierPayloadContext<IRouterParamContext & Context>;

View file

@ -0,0 +1,257 @@
import type { SignInExperience } from '@logto/schemas';
import { SignUpIdentifier, SignInIdentifier, SignInMode } from '@logto/schemas';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { signInModeValidation, identifierValidation } from './sign-in-experience-validation.js';
describe('signInModeValidation', () => {
it('register', () => {
expect(() => {
signInModeValidation('register', { signInMode: SignInMode.SignIn } as SignInExperience);
}).toThrow();
expect(() => {
signInModeValidation('register', { signInMode: SignInMode.Register } as SignInExperience);
}).not.toThrow();
expect(() => {
signInModeValidation('register', {
signInMode: SignInMode.SignInAndRegister,
} as SignInExperience);
}).not.toThrow();
});
it('SignIn', () => {
expect(() => {
signInModeValidation('sign-in', { signInMode: SignInMode.SignIn } as SignInExperience);
}).not.toThrow();
expect(() => {
signInModeValidation('sign-in', { signInMode: SignInMode.Register } as SignInExperience);
}).toThrow();
expect(() => {
signInModeValidation('sign-in', {
signInMode: SignInMode.SignInAndRegister,
} as SignInExperience);
}).not.toThrow();
});
it('forgot-password', () => {
expect(() => {
signInModeValidation('forgot-password', {
signInMode: SignInMode.SignIn,
} as SignInExperience);
}).not.toThrow();
expect(() => {
signInModeValidation('forgot-password', {
signInMode: SignInMode.Register,
} as SignInExperience);
}).not.toThrow();
expect(() => {
signInModeValidation('forgot-password', {
signInMode: SignInMode.SignInAndRegister,
} as SignInExperience);
}).not.toThrow();
});
});
describe('identifier validation', () => {
it('username-password', () => {
const identifier = { username: 'username', password: 'password' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier !== SignInIdentifier.Username
),
},
});
}).toThrow();
});
it('email password', () => {
const identifier = { email: 'email', password: 'password' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier !== SignInIdentifier.Email
),
},
});
}).toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signIn: {
methods: [
{
identifier: SignInIdentifier.Email,
password: false,
verificationCode: true,
isPasswordPrimary: true,
},
],
},
});
}).toThrow();
});
it('email passcode', () => {
const identifier = { email: 'email', passcode: 'passcode' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier !== SignInIdentifier.Email
),
},
});
}).toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signIn: {
methods: [
{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
});
}).toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signUp: {
identifier: SignUpIdentifier.Email,
password: false,
verify: true,
},
signIn: {
methods: [
{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
});
}).not.toThrow();
});
it('phone password', () => {
const identifier = { phone: '123', password: 'password' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier !== SignInIdentifier.Sms
),
},
});
}).toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signIn: {
methods: [
{
identifier: SignInIdentifier.Sms,
password: false,
verificationCode: true,
isPasswordPrimary: true,
},
],
},
});
}).toThrow();
});
it('phone passcode', () => {
const identifier = { phone: '123456', passcode: 'passcode' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier !== SignInIdentifier.Sms
),
},
});
}).toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signIn: {
methods: [
{
identifier: SignInIdentifier.Sms,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
});
}).toThrow();
expect(() => {
identifierValidation(identifier, {
...mockSignInExperience,
signUp: {
identifier: SignUpIdentifier.Sms,
password: false,
verify: true,
},
signIn: {
methods: [
{
identifier: SignInIdentifier.Sms,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
});
}).not.toThrow();
});
});

View file

@ -0,0 +1,133 @@
import type { Event, SignInExperience, Profile } from '@logto/schemas';
import { SignUpIdentifier, SignInMode, SignInIdentifier } from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.js';
import type { IdentifierPayload } from '../types/guard.js';
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
const forbiddenIdentifierError = new RequestError({
code: 'user.sign_in_method_not_enabled',
status: 422,
});
export const signInModeValidation = (event: Event, { signInMode }: SignInExperience) => {
if (event === 'sign-in') {
assertThat(signInMode !== SignInMode.Register, forbiddenEventError);
}
if (event === 'register') {
assertThat(signInMode !== SignInMode.SignIn, forbiddenEventError);
}
};
export const identifierValidation = (
identifier: IdentifierPayload,
signInExperience: SignInExperience
) => {
const { signIn, signUp } = signInExperience;
// Username Password Identifier
if ('username' in identifier) {
assertThat(
signIn.methods.some(
({ identifier: method, password }) => method === SignInIdentifier.Username && password
),
forbiddenIdentifierError
);
return;
}
// Email Identifier
if ('email' in identifier) {
assertThat(
// eslint-disable-next-line complexity
signIn.methods.some(({ identifier: method, password, verificationCode }) => {
if (method !== SignInIdentifier.Email) {
return false;
}
// Email Password Verification
if ('password' in identifier && !password) {
return false;
}
// Email Passcode Verification: SignIn verificationCode enabled or SignUp Email verify enabled
if (
'passcode' in identifier &&
!verificationCode &&
![SignUpIdentifier.Email, SignUpIdentifier.EmailOrSms].includes(signUp.identifier) &&
!signUp.verify
) {
return false;
}
return true;
}),
forbiddenIdentifierError
);
return;
}
// Phone Identifier
if ('phone' in identifier) {
assertThat(
// eslint-disable-next-line complexity
signIn.methods.some(({ identifier: method, password, verificationCode }) => {
if (method !== SignInIdentifier.Sms) {
return false;
}
// Phone Password Verification
if ('password' in identifier && !password) {
return false;
}
// Phone Passcode Verification: SignIn verificationCode enabled or SignUp Email verify enabled
if (
'passcode' in identifier &&
!verificationCode &&
![SignUpIdentifier.Sms, SignUpIdentifier.EmailOrSms].includes(signUp.identifier) &&
!signUp.verify
) {
return false;
}
return true;
}),
forbiddenIdentifierError
);
}
// Social Identifier TODO: @darcy, @sijie
};
export const profileValidation = (profile: Profile, { signUp }: SignInExperience) => {
if (profile.phone) {
assertThat(
signUp.identifier === SignUpIdentifier.Sms ||
signUp.identifier === SignUpIdentifier.EmailOrSms,
forbiddenIdentifierError
);
}
if (profile.email) {
assertThat(
signUp.identifier === SignUpIdentifier.Email ||
signUp.identifier === SignUpIdentifier.EmailOrSms,
forbiddenIdentifierError
);
}
if (profile.username) {
assertThat(signUp.identifier === SignUpIdentifier.Username, forbiddenIdentifierError);
}
if (profile.password) {
assertThat(signUp.password, forbiddenIdentifierError);
}
};

View file

@ -1,8 +1,4 @@
import { SignInIdentifier } from '@logto/schemas';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { verifyUserPassword } from '#src/lib/user.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import verifyUserByPassword from './verify-user-by-password.js';
@ -12,57 +8,14 @@ jest.mock('#src/lib/user.js', () => ({
describe('verifyUserByPassword', () => {
const findUser = jest.fn();
const baseCtx = createContextWithRouteParameters();
const verifyUserPasswordMock = verifyUserPassword as jest.Mock;
const mockUser = { id: 'mock_user', isSuspended: false };
it('should throw if target sign-in method is not enabled', async () => {
const ctx = {
...baseCtx,
interactionPayload: {},
signInExperience: {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier === SignInIdentifier.Username
),
},
},
};
await expect(
verifyUserByPassword(ctx, {
identifier: 'foo',
password: 'password',
findUser,
identifierType: SignInIdentifier.Email,
})
).rejects.toThrow();
});
it('should return userId', async () => {
findUser.mockResolvedValueOnce(mockUser);
verifyUserPasswordMock.mockResolvedValueOnce(mockUser);
const ctx = {
...baseCtx,
interactionPayload: {},
signInExperience: {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier === SignInIdentifier.Username
),
},
},
};
const userId = await verifyUserByPassword(ctx, {
identifier: 'foo',
password: 'password',
findUser,
identifierType: SignInIdentifier.Username,
});
const userId = await verifyUserByPassword('foo', 'password', findUser);
expect(findUser).toBeCalledWith('foo');
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, 'password');
@ -76,27 +29,7 @@ describe('verifyUserByPassword', () => {
isSuspended: true,
});
const ctx = {
...baseCtx,
interactionPayload: {},
signInExperience: {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier === SignInIdentifier.Username
),
},
},
};
await expect(
verifyUserByPassword(ctx, {
identifier: 'foo',
password: 'password',
findUser,
identifierType: SignInIdentifier.Username,
})
).rejects.toThrow();
await expect(verifyUserByPassword('foo', 'password', findUser)).rejects.toThrow();
expect(findUser).toBeCalledWith('foo');
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, 'password');

View file

@ -1,35 +1,15 @@
import type { SignInIdentifier, User } from '@logto/schemas';
import type { User } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
import { verifyUserPassword } from '#src/lib/user.js';
import assertThat from '#src/utils/assert-that.js';
import type { InteractionContext } from '../types/index.js';
type Parameters = {
identifier: string;
password: string;
findUser: (identifier: string) => Promise<Nullable<User>>;
identifierType: SignInIdentifier;
};
export default async function verifyUserByPassword(
ctx: InteractionContext,
{ identifier, password, findUser, identifierType }: Parameters
identifier: string,
password: string,
findUser: (identifier: string) => Promise<Nullable<User>>
) {
const { signIn } = ctx.signInExperience;
assertThat(
signIn.methods.some(
({ identifier: method, password }) => method === identifierType && password
),
new RequestError({
code: 'user.sign_in_method_not_enabled',
status: 422,
})
);
const user = await findUser(identifier);
const verifiedUser = await verifyUserPassword(user, password);
const { isSuspended, id } = verifiedUser;

View file

@ -1,6 +1,3 @@
import { SignInIdentifier } from '@logto/schemas';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { findUserByUsername, findUserByEmail, findUserByPhone } from '#src/queries/user.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -15,12 +12,15 @@ describe('identifier verification', () => {
const baseCtx = createContextWithRouteParameters();
const verifyUserByPasswordMock = verifyUserByPassword as jest.Mock;
afterEach(() => {
jest.clearAllMocks();
});
it('username password', async () => {
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
const ctx = {
...baseCtx,
signInExperience: mockSignInExperience,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier: {
@ -32,12 +32,7 @@ describe('identifier verification', () => {
const result = await identifierVerification(ctx);
expect(verifyUserByPasswordMock).toBeCalledWith(ctx, {
findUser: findUserByUsername,
identifier: 'username',
identifierType: SignInIdentifier.Username,
password: 'password',
});
expect(verifyUserByPasswordMock).toBeCalledWith('username', 'password', findUserByUsername);
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
});
@ -46,7 +41,6 @@ describe('identifier verification', () => {
const ctx = {
...baseCtx,
signInExperience: mockSignInExperience,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier: {
@ -58,12 +52,7 @@ describe('identifier verification', () => {
const result = await identifierVerification(ctx);
expect(verifyUserByPasswordMock).toBeCalledWith(ctx, {
findUser: findUserByEmail,
identifier: 'email',
identifierType: SignInIdentifier.Email,
password: 'password',
});
expect(verifyUserByPasswordMock).toBeCalledWith('email', 'password', findUserByEmail);
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
});
@ -72,7 +61,6 @@ describe('identifier verification', () => {
const ctx = {
...baseCtx,
signInExperience: mockSignInExperience,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier: {
@ -84,12 +72,7 @@ describe('identifier verification', () => {
const result = await identifierVerification(ctx);
expect(verifyUserByPasswordMock).toBeCalledWith(ctx, {
findUser: findUserByPhone,
identifier: '123456',
identifierType: SignInIdentifier.Sms,
password: 'password',
});
expect(verifyUserByPasswordMock).toBeCalledWith('123456', 'password', findUserByPhone);
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
});
});

View file

@ -1,9 +1,6 @@
import { SignInIdentifier } from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
import { findUserByEmail, findUserByPhone, findUserByUsername } from '#src/queries/user.js';
import { isUsernamePassword, isPhonePassword, isEmailPassword } from '../types/guard.js';
import type { InteractionContext, Identifier } from '../types/index.js';
import { verifyUserByPassword } from '../utils/index.js';
@ -12,41 +9,30 @@ export default async function identifierVerification(
): Promise<Identifier[]> {
const { identifier } = ctx.interactionPayload;
if (isUsernamePassword(identifier)) {
if (!identifier) {
return [];
}
if ('username' in identifier) {
const { username, password } = identifier;
const accountId = await verifyUserByPassword(ctx, {
identifier: username,
password,
findUser: findUserByUsername,
identifierType: SignInIdentifier.Username,
});
const accountId = await verifyUserByPassword(username, password, findUserByUsername);
return [{ key: 'accountId', value: accountId }];
}
if (isPhonePassword(identifier)) {
if ('phone' in identifier && 'password' in identifier) {
const { phone, password } = identifier;
const accountId = await verifyUserByPassword(ctx, {
identifier: phone,
password,
findUser: findUserByPhone,
identifierType: SignInIdentifier.Sms,
});
const accountId = await verifyUserByPassword(phone, password, findUserByPhone);
return [{ key: 'accountId', value: accountId }];
}
if (isEmailPassword(identifier)) {
if ('email' in identifier && 'password' in identifier) {
const { email, password } = identifier;
const accountId = await verifyUserByPassword(ctx, {
identifier: email,
password,
findUser: findUserByEmail,
identifierType: SignInIdentifier.Email,
});
const accountId = await verifyUserByPassword(email, password, findUserByEmail);
return [{ key: 'accountId', value: accountId }];
}

View file

@ -52,15 +52,14 @@ export const eventGuard = z.union([
export type Event = z.infer<typeof eventGuard>;
export const identifierGuard = z.object({
username: z.string().min(1).optional(),
email: z.string().min(1).optional(),
phone: z.string().min(1).optional(),
connectorId: z.string().min(1).optional(),
password: z.string().min(1).optional(),
passcode: z.string().min(1).optional(),
connectorData: z.unknown().optional(),
});
export const identifierGuard = z.union([
usernamePasswordPayloadGuard,
emailPasswordPayloadGuard,
phonePasswordPayloadGuard,
emailPasscodePayloadGuard,
phonePasscodePayloadGuard,
socialConnectorPayloadGuard,
]);
export const profileGuard = z.object({
username: z.string().regex(usernameRegEx).optional(),

251
pnpm-lock.yaml generated
View file

@ -323,7 +323,7 @@ importers:
snake-case: ^3.0.4
snakecase-keys: ^5.4.4
supertest: ^6.2.2
typescript: ^4.7.4
typescript: ^4.9.3
zod: ^3.19.1
dependencies:
'@logto/cli': link:../cli
@ -372,9 +372,9 @@ importers:
zod: 3.19.1
devDependencies:
'@shopify/jest-koa-mocks': 5.0.1
'@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni
'@silverhand/jest-config': 1.2.2_zapogttls25djihwjkusccjjym
'@silverhand/ts-config': 1.2.1_typescript@4.7.4
'@silverhand/eslint-config': 1.3.0_xygfz6avl43ipur7dlp2av7gnm
'@silverhand/jest-config': 1.2.2_gxkpbehbojmgu22invxph4jlwq
'@silverhand/ts-config': 1.2.1_typescript@4.9.3
'@types/debug': 4.1.7
'@types/etag': 1.8.1
'@types/fs-extra': 9.0.13
@ -401,7 +401,7 @@ importers:
openapi-types: 12.0.0
prettier: 2.7.1
supertest: 6.2.2
typescript: 4.7.4
typescript: 4.9.3
packages/create:
specifiers:
@ -3569,6 +3569,38 @@ packages:
- typescript
dev: true
/@silverhand/eslint-config/1.3.0_xygfz6avl43ipur7dlp2av7gnm:
resolution: {integrity: sha512-0+SXJXAkUe1pg2DNn3JCEo99Weev07chQsL2iSCramXeMKjEk1R1UKjgQJM9saUGF7ovY4hlE/JjFD3PFId4DQ==}
engines: {node: ^16.0.0 || ^18.0.0}
peerDependencies:
eslint: ^8.21.0
prettier: ^2.7.1
dependencies:
'@silverhand/eslint-plugin-fp': 2.5.0_eslint@8.21.0
'@typescript-eslint/eslint-plugin': 5.40.0_ujnp3qqzcos2fcjl53ed5mxtmq
'@typescript-eslint/parser': 5.40.0_4he5nxxgrmu5gxjroamasnmd3i
eslint: 8.21.0
eslint-config-prettier: 8.5.0_eslint@8.21.0
eslint-config-xo: 0.42.0_eslint@8.21.0
eslint-config-xo-typescript: 0.53.0_6262kjopfp2ssqpmwkpdbrlzgu
eslint-import-resolver-typescript: 3.5.1_jatgrcxl4x7ywe7ak6cnjca2ae
eslint-plugin-consistent-default-export-name: 0.0.15
eslint-plugin-eslint-comments: 3.2.0_eslint@8.21.0
eslint-plugin-import: 2.26.0_7tkpoacjify653e7qftl64vwym
eslint-plugin-no-use-extend-native: 0.5.0
eslint-plugin-node: 11.1.0_eslint@8.21.0
eslint-plugin-prettier: 4.2.1_h62lvancfh4b7r6zn2dgodrh5e
eslint-plugin-promise: 6.1.0_eslint@8.21.0
eslint-plugin-sql: 2.1.0_eslint@8.21.0
eslint-plugin-unicorn: 43.0.2_eslint@8.21.0
eslint-plugin-unused-imports: 2.0.0_kjyxfvacupbf4yx7sz4dzjz4we
prettier: 2.7.1
transitivePeerDependencies:
- eslint-import-resolver-webpack
- supports-color
- typescript
dev: true
/@silverhand/eslint-plugin-fp/2.5.0_eslint@8.21.0:
resolution: {integrity: sha512-/oLO2Rs9nkhOk+rmC3PsWDvrDKrOfKuRtbSAwH4Scawn5GqAjo7ZXIZXj7RWa4nxLsCGc3ULvaVs1e1m4n6G/A==}
engines: {node: '>=14.15.0'}
@ -3589,6 +3621,26 @@ packages:
lodash.orderby: 4.6.0
lodash.pick: 4.4.0
/@silverhand/jest-config/1.2.2_gxkpbehbojmgu22invxph4jlwq:
resolution: {integrity: sha512-sCOIHN3kIG9nyySkDao8nz6HK8VhGoUV4WG1CCriDDeGTqbHs4IprzTp1p+ChFdC8JGBCElQC0cIFrWYTFnTAQ==}
engines: {node: ^16.0.0 || ^18.0.0}
peerDependencies:
jest: ^29.0.0 || ^29.1.2
dependencies:
'@jest/types': 29.1.2
deepmerge: 4.2.2
identity-obj-proxy: 3.0.0
jest: 29.1.2_@types+node@16.11.12
jest-matcher-specific-error: 1.0.0
jest-transform-stub: 2.0.0
ts-jest: 29.0.3_lr7fqxhx6o7ex6ma5v5npbw6ae
transitivePeerDependencies:
- '@babel/core'
- babel-jest
- esbuild
- typescript
dev: true
/@silverhand/jest-config/1.2.2_wkdujqsgbnfnnp5xidismkcn6e:
resolution: {integrity: sha512-sCOIHN3kIG9nyySkDao8nz6HK8VhGoUV4WG1CCriDDeGTqbHs4IprzTp1p+ChFdC8JGBCElQC0cIFrWYTFnTAQ==}
engines: {node: ^16.0.0 || ^18.0.0}
@ -3648,6 +3700,15 @@ packages:
typescript: 4.7.4
dev: true
/@silverhand/ts-config/1.2.1_typescript@4.9.3:
resolution: {integrity: sha512-Lm5Ydb45qKmXvlOfQfSb+1WHrdL5IBtzt+AMOR5h528H073FLzaazLiaDo4noBVT9PAVtO7kG9qjwSPzHf0k9Q==}
engines: {node: ^16.0.0 || ^18.0.0}
peerDependencies:
typescript: ^4.7.4
dependencies:
typescript: 4.9.3
dev: true
/@sinclair/typebox/0.24.46:
resolution: {integrity: sha512-ng4ut1z2MCBhK/NwDVwIQp3pAUOCs/KNaW3cBxdFB2xTDrOuo1xuNmpr/9HHFhxqIvHrs1NTH3KJg6q+JSy1Kw==}
dev: true
@ -4447,6 +4508,52 @@ packages:
- supports-color
dev: true
/@typescript-eslint/eslint-plugin/5.40.0_ujnp3qqzcos2fcjl53ed5mxtmq:
resolution: {integrity: sha512-FIBZgS3DVJgqPwJzvZTuH4HNsZhHMa9SjxTKAZTlMsPw/UzpEjcf9f4dfgDJEHjK+HboUJo123Eshl6niwEm/Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
'@typescript-eslint/parser': ^5.0.0
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/parser': 5.40.0_4he5nxxgrmu5gxjroamasnmd3i
'@typescript-eslint/scope-manager': 5.40.0
'@typescript-eslint/type-utils': 5.40.0_4he5nxxgrmu5gxjroamasnmd3i
'@typescript-eslint/utils': 5.40.0_4he5nxxgrmu5gxjroamasnmd3i
debug: 4.3.4
eslint: 8.21.0
ignore: 5.2.0
regexpp: 3.2.0
semver: 7.3.8
tsutils: 3.21.0_typescript@4.9.3
typescript: 4.9.3
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/parser/5.40.0_4he5nxxgrmu5gxjroamasnmd3i:
resolution: {integrity: sha512-Ah5gqyX2ySkiuYeOIDg7ap51/b63QgWZA7w6AHtFrag7aH0lRQPbLzUjk0c9o5/KZ6JRkTTDKShL4AUrQa6/hw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/scope-manager': 5.40.0
'@typescript-eslint/types': 5.40.0
'@typescript-eslint/typescript-estree': 5.40.0_typescript@4.9.3
debug: 4.3.4
eslint: 8.21.0
typescript: 4.9.3
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/parser/5.40.0_qugx7qdu5zevzvxaiqyxfiwquq:
resolution: {integrity: sha512-Ah5gqyX2ySkiuYeOIDg7ap51/b63QgWZA7w6AHtFrag7aH0lRQPbLzUjk0c9o5/KZ6JRkTTDKShL4AUrQa6/hw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -4475,6 +4582,26 @@ packages:
'@typescript-eslint/visitor-keys': 5.40.0
dev: true
/@typescript-eslint/type-utils/5.40.0_4he5nxxgrmu5gxjroamasnmd3i:
resolution: {integrity: sha512-nfuSdKEZY2TpnPz5covjJqav+g5qeBqwSHKBvz7Vm1SAfy93SwKk/JeSTymruDGItTwNijSsno5LhOHRS1pcfw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '*'
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/typescript-estree': 5.40.0_typescript@4.9.3
'@typescript-eslint/utils': 5.40.0_4he5nxxgrmu5gxjroamasnmd3i
debug: 4.3.4
eslint: 8.21.0
tsutils: 3.21.0_typescript@4.9.3
typescript: 4.9.3
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/type-utils/5.40.0_qugx7qdu5zevzvxaiqyxfiwquq:
resolution: {integrity: sha512-nfuSdKEZY2TpnPz5covjJqav+g5qeBqwSHKBvz7Vm1SAfy93SwKk/JeSTymruDGItTwNijSsno5LhOHRS1pcfw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -4521,6 +4648,46 @@ packages:
- supports-color
dev: true
/@typescript-eslint/typescript-estree/5.40.0_typescript@4.9.3:
resolution: {integrity: sha512-b0GYlDj8TLTOqwX7EGbw2gL5EXS2CPEWhF9nGJiGmEcmlpNBjyHsTwbqpyIEPVpl6br4UcBOYlcI2FJVtJkYhg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/types': 5.40.0
'@typescript-eslint/visitor-keys': 5.40.0
debug: 4.3.4
globby: 11.1.0
is-glob: 4.0.3
semver: 7.3.8
tsutils: 3.21.0_typescript@4.9.3
typescript: 4.9.3
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/utils/5.40.0_4he5nxxgrmu5gxjroamasnmd3i:
resolution: {integrity: sha512-MO0y3T5BQ5+tkkuYZJBjePewsY+cQnfkYeRqS6tPh28niiIwPnQ1t59CSRcs1ZwJJNOdWw7rv9pF8aP58IMihA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
'@types/json-schema': 7.0.11
'@typescript-eslint/scope-manager': 5.40.0
'@typescript-eslint/types': 5.40.0
'@typescript-eslint/typescript-estree': 5.40.0_typescript@4.9.3
eslint: 8.21.0
eslint-scope: 5.1.1
eslint-utils: 3.0.0_eslint@8.21.0
semver: 7.3.8
transitivePeerDependencies:
- supports-color
- typescript
dev: true
/@typescript-eslint/utils/5.40.0_qugx7qdu5zevzvxaiqyxfiwquq:
resolution: {integrity: sha512-MO0y3T5BQ5+tkkuYZJBjePewsY+cQnfkYeRqS6tPh28niiIwPnQ1t59CSRcs1ZwJJNOdWw7rv9pF8aP58IMihA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -6447,6 +6614,21 @@ packages:
typescript: 4.7.4
dev: true
/eslint-config-xo-typescript/0.53.0_6262kjopfp2ssqpmwkpdbrlzgu:
resolution: {integrity: sha512-IJ1n70egMPTou/41HoGGFbLf/2WCsVW5lSUxOSklrR8T1221fMRPVJxIVZ3evr8R+N5wR6uzg/0uzSymwWA5Bg==}
engines: {node: '>=12'}
peerDependencies:
'@typescript-eslint/eslint-plugin': '>=5.31.0'
'@typescript-eslint/parser': '>=5.31.0'
eslint: '>=8.0.0'
typescript: '>=4.4'
dependencies:
'@typescript-eslint/eslint-plugin': 5.40.0_ujnp3qqzcos2fcjl53ed5mxtmq
'@typescript-eslint/parser': 5.40.0_4he5nxxgrmu5gxjroamasnmd3i
eslint: 8.21.0
typescript: 4.9.3
dev: true
/eslint-config-xo/0.42.0_eslint@8.21.0:
resolution: {integrity: sha512-HIfd+AM6tHFoaZ/NXYDV3Mr/CJrAj/DoP6IOYt1/v+90XtCwVYOfW7LXbRDYDmhQMzT16h7eqPRcex72waRqdA==}
engines: {node: '>=12'}
@ -6507,7 +6689,7 @@ packages:
eslint-import-resolver-webpack:
optional: true
dependencies:
'@typescript-eslint/parser': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq
'@typescript-eslint/parser': 5.40.0_4he5nxxgrmu5gxjroamasnmd3i
debug: 3.2.7
eslint: 8.21.0
eslint-import-resolver-node: 0.3.6
@ -6556,7 +6738,7 @@ packages:
'@typescript-eslint/parser':
optional: true
dependencies:
'@typescript-eslint/parser': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq
'@typescript-eslint/parser': 5.40.0_4he5nxxgrmu5gxjroamasnmd3i
array-includes: 3.1.5
array.prototype.flat: 1.3.0
debug: 2.6.9
@ -6731,7 +6913,7 @@ packages:
'@typescript-eslint/eslint-plugin':
optional: true
dependencies:
'@typescript-eslint/eslint-plugin': 5.40.0_bomoubwgcm5gub6ncofkqpat4u
'@typescript-eslint/eslint-plugin': 5.40.0_ujnp3qqzcos2fcjl53ed5mxtmq
eslint: 8.21.0
eslint-rule-composer: 0.3.0
dev: true
@ -7584,6 +7766,7 @@ packages:
/graceful-fs/4.2.10:
resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
dev: true
/graceful-fs/4.2.9:
resolution: {integrity: sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==}
@ -9939,7 +10122,7 @@ packages:
dependencies:
universalify: 2.0.0
optionalDependencies:
graceful-fs: 4.2.10
graceful-fs: 4.2.9
/jsonparse/1.3.1:
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
@ -14308,6 +14491,40 @@ packages:
yargs-parser: 21.1.1
dev: true
/ts-jest/29.0.3_lr7fqxhx6o7ex6ma5v5npbw6ae:
resolution: {integrity: sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
peerDependencies:
'@babel/core': '>=7.0.0-beta.0 <8'
'@jest/types': ^29.0.0
babel-jest: ^29.0.0
esbuild: '*'
jest: ^29.0.0 || ^29.1.2
typescript: '>=4.3'
peerDependenciesMeta:
'@babel/core':
optional: true
'@jest/types':
optional: true
babel-jest:
optional: true
esbuild:
optional: true
dependencies:
'@jest/types': 29.1.2
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
jest: 29.1.2_@types+node@16.11.12
jest-util: 29.2.1
json5: 2.2.1
lodash.memoize: 4.1.2
make-error: 1.3.6
semver: 7.3.8
typescript: 4.9.3
yargs-parser: 21.1.1
dev: true
/ts-jest/29.0.3_o3wtcjdhyxuv43bggxcaucanwu:
resolution: {integrity: sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -14442,6 +14659,16 @@ packages:
typescript: 4.7.4
dev: true
/tsutils/3.21.0_typescript@4.9.3:
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
engines: {node: '>= 6'}
peerDependencies:
typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta'
dependencies:
tslib: 1.14.1
typescript: 4.9.3
dev: true
/tty-table/4.1.6:
resolution: {integrity: sha512-kRj5CBzOrakV4VRRY5kUWbNYvo/FpOsz65DzI5op9P+cHov3+IqPbo1JE1ZnQGkHdZgNFDsrEjrfqqy/Ply9fw==}
engines: {node: '>=8.0.0'}
@ -14533,6 +14760,12 @@ packages:
hasBin: true
dev: true
/typescript/4.9.3:
resolution: {integrity: sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==}
engines: {node: '>=4.2.0'}
hasBin: true
dev: true
/ua-parser-js/1.0.2:
resolution: {integrity: sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==}
dev: true