mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
refactor(ui): add password identifier verification (#2515)
This commit is contained in:
parent
9e2c536a33
commit
fb6fd19021
11 changed files with 363 additions and 16 deletions
|
@ -1,8 +1,12 @@
|
|||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { AnonymousRouter } from '../types.js';
|
||||
import koaInteractionBodyGuard from './middleware/koa-interaction-body-guard.js';
|
||||
import koaSessionSignInExperienceGuard from './middleware/koa-session-sign-in-experience-guard.js';
|
||||
import { identifierVerification } from './verifications/index.js';
|
||||
|
||||
export default function interactionRoutes<T extends AnonymousRouter>(
|
||||
router: T,
|
||||
|
@ -16,19 +20,10 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
// Check interaction session
|
||||
await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
const { event } = ctx.interactionPayload;
|
||||
// PUT method must provides an event type
|
||||
assertThat(ctx.interactionPayload.event, new RequestError('guard.invalid_input'));
|
||||
|
||||
if (event === 'sign-in') {
|
||||
// Sign-in flow
|
||||
return next();
|
||||
}
|
||||
|
||||
if (event === 'register') {
|
||||
// Register flow
|
||||
return next();
|
||||
}
|
||||
|
||||
// Forgot password flow
|
||||
const verifiedIdentifiers = await identifierVerification(ctx);
|
||||
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -12,7 +12,9 @@ import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body
|
|||
|
||||
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
|
||||
|
||||
export type WithSignInExperienceContext<ContextT> = ContextT & {
|
||||
export type WithSignInExperienceContext<
|
||||
ContextT extends WithGuardedIdentifierPayloadContext<IRouterParamContext>
|
||||
> = ContextT & {
|
||||
signInExperience: SignInExperience;
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,22 @@
|
|||
import { eventGuard, profileGuard, identifierGuard } from '@logto/schemas';
|
||||
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 { z } from 'zod';
|
||||
|
||||
export const interactionPayloadGuard = z.object({
|
||||
|
@ -8,3 +26,29 @@ 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;
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
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
|
||||
| VerifiedEmailIdentifier
|
||||
|
@ -19,3 +25,7 @@ type UseInfo = {
|
|||
avatar?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type InteractionContext = WithSignInExperienceContext<
|
||||
WithGuardedIdentifierPayloadContext<IRouterParamContext & Context>
|
||||
>;
|
||||
|
|
1
packages/core/src/routes/interaction/utils/index.ts
Normal file
1
packages/core/src/routes/interaction/utils/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as verifyUserByPassword } from './verify-user-by-password.js';
|
|
@ -0,0 +1,104 @@
|
|||
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';
|
||||
|
||||
jest.mock('#src/lib/user.js', () => ({
|
||||
verifyUserPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
expect(findUser).toBeCalledWith('foo');
|
||||
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, 'password');
|
||||
expect(userId).toEqual(mockUser.id);
|
||||
});
|
||||
|
||||
it('should reject if user is suspended', async () => {
|
||||
findUser.mockResolvedValueOnce(mockUser);
|
||||
verifyUserPasswordMock.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
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();
|
||||
|
||||
expect(findUser).toBeCalledWith('foo');
|
||||
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, 'password');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
import type { SignInIdentifier, 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
|
||||
) {
|
||||
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;
|
||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||
|
||||
return id;
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
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';
|
||||
|
||||
import { verifyUserByPassword } from '../utils/index.js';
|
||||
import identifierVerification from './identifier-verification.js';
|
||||
|
||||
jest.mock('../utils/index.js', () => ({
|
||||
verifyUserByPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('identifier verification', () => {
|
||||
const baseCtx = createContextWithRouteParameters();
|
||||
const verifyUserByPasswordMock = verifyUserByPassword as jest.Mock;
|
||||
|
||||
it('username password', async () => {
|
||||
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
|
||||
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: mockSignInExperience,
|
||||
interactionPayload: Object.freeze({
|
||||
event: 'sign-in',
|
||||
identifier: {
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await identifierVerification(ctx);
|
||||
|
||||
expect(verifyUserByPasswordMock).toBeCalledWith(ctx, {
|
||||
findUser: findUserByUsername,
|
||||
identifier: 'username',
|
||||
identifierType: SignInIdentifier.Username,
|
||||
password: 'password',
|
||||
});
|
||||
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
|
||||
});
|
||||
|
||||
it('email password', async () => {
|
||||
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
|
||||
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: mockSignInExperience,
|
||||
interactionPayload: Object.freeze({
|
||||
event: 'sign-in',
|
||||
identifier: {
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await identifierVerification(ctx);
|
||||
|
||||
expect(verifyUserByPasswordMock).toBeCalledWith(ctx, {
|
||||
findUser: findUserByEmail,
|
||||
identifier: 'email',
|
||||
identifierType: SignInIdentifier.Email,
|
||||
password: 'password',
|
||||
});
|
||||
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
|
||||
});
|
||||
|
||||
it('phone password', async () => {
|
||||
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
|
||||
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: mockSignInExperience,
|
||||
interactionPayload: Object.freeze({
|
||||
event: 'sign-in',
|
||||
identifier: {
|
||||
phone: '123456',
|
||||
password: 'password',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await identifierVerification(ctx);
|
||||
|
||||
expect(verifyUserByPasswordMock).toBeCalledWith(ctx, {
|
||||
findUser: findUserByPhone,
|
||||
identifier: '123456',
|
||||
identifierType: SignInIdentifier.Sms,
|
||||
password: 'password',
|
||||
});
|
||||
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
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';
|
||||
|
||||
export default async function identifierVerification(
|
||||
ctx: InteractionContext
|
||||
): Promise<Identifier[]> {
|
||||
const { identifier } = ctx.interactionPayload;
|
||||
|
||||
if (isUsernamePassword(identifier)) {
|
||||
const { username, password } = identifier;
|
||||
|
||||
const accountId = await verifyUserByPassword(ctx, {
|
||||
identifier: username,
|
||||
password,
|
||||
findUser: findUserByUsername,
|
||||
identifierType: SignInIdentifier.Username,
|
||||
});
|
||||
|
||||
return [{ key: 'accountId', value: accountId }];
|
||||
}
|
||||
|
||||
if (isPhonePassword(identifier)) {
|
||||
const { phone, password } = identifier;
|
||||
|
||||
const accountId = await verifyUserByPassword(ctx, {
|
||||
identifier: phone,
|
||||
password,
|
||||
findUser: findUserByPhone,
|
||||
identifierType: SignInIdentifier.Sms,
|
||||
});
|
||||
|
||||
return [{ key: 'accountId', value: accountId }];
|
||||
}
|
||||
|
||||
if (isEmailPassword(identifier)) {
|
||||
const { email, password } = identifier;
|
||||
|
||||
const accountId = await verifyUserByPassword(ctx, {
|
||||
identifier: email,
|
||||
password,
|
||||
findUser: findUserByEmail,
|
||||
identifierType: SignInIdentifier.Email,
|
||||
});
|
||||
|
||||
return [{ key: 'accountId', value: accountId }];
|
||||
}
|
||||
|
||||
// Invalid identifier input
|
||||
throw new RequestError('guard.invalid_input', identifier);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default as identifierVerification } from './identifier-verification.js';
|
|
@ -24,13 +24,13 @@ export const phonePasswordPayloadGuard = z.object({
|
|||
export type PhonePasswordPayload = z.infer<typeof phonePasswordPayloadGuard>;
|
||||
|
||||
export const emailPasscodePayloadGuard = z.object({
|
||||
email: z.string().min(1),
|
||||
email: z.string().regex(emailRegEx),
|
||||
passcode: z.string().min(1),
|
||||
});
|
||||
export type EmailPasscodePayload = z.infer<typeof emailPasscodePayloadGuard>;
|
||||
|
||||
export const phonePasscodePayloadGuard = z.object({
|
||||
phone: z.string().min(1),
|
||||
phone: z.string().regex(phoneRegEx),
|
||||
passcode: z.string().min(1),
|
||||
});
|
||||
export type PhonePasscodePayload = z.infer<typeof phonePasscodePayloadGuard>;
|
||||
|
|
Loading…
Add table
Reference in a new issue